Explorar el Código

Merge branch 'dev' into dev-rewardPoints

bianjunhui hace 4 días
padre
commit
d71bdc155e
Se han modificado 100 ficheros con 7179 adiciones y 293 borrados
  1. 384 0
      app/Console/Commands/MigrateAsteriaReviews.php
  2. 153 0
      app/Jobs/MigrateReviewJob.php
  3. 70 68
      bootstrap/cache/services.php
  4. 1 0
      bootstrap/providers.php
  5. 1 0
      composer.json
  6. 3 0
      config/api-platform.php
  7. 32 0
      config/database.php
  8. 23 22
      packages/Longyi/Gift/src/Http/Controllers/Shop/GiftController.php
  9. 1 0
      packages/Longyi/Gift/src/Models/GiftCards.php
  10. 71 0
      packages/Longyi/Gift/src/Services/GiftCardService.php
  11. 1 1
      packages/Longyi/Member/src/Listeners/MemberHandler.php
  12. 6 2
      packages/Longyi/Member/src/Listeners/VipDiscountHandler.php
  13. 53 0
      packages/Longyi/Member/src/Services/MemberDiscountService.php
  14. 36 6
      packages/Longyi/Pay/Afterpay/src/Config/system.php
  15. 29 13
      packages/Longyi/Pay/Afterpay/src/Http/Controllers/AfterpayController.php
  16. 116 10
      packages/Longyi/Pay/Afterpay/src/Payment/Afterpay.php
  17. 21 11
      packages/Longyi/Pay/Afterpay/src/Payment/Clearpay.php
  18. 4 0
      packages/Longyi/Pay/Afterpay/src/Resources/views/admin/system/update-limits.blade.php
  19. 25 4
      packages/Longyi/Pay/Airwallex/src/Http/Controllers/AirwallexController.php
  20. 1 1
      packages/Longyi/Pay/Airwallex/src/Http/routes.php
  21. 9 3
      packages/Longyi/Pay/Airwallex/src/Payment/Airwallex.php
  22. 22 2
      packages/Longyi/Pay/Applepay/src/Http/Controllers/ApplepayController.php
  23. 1 1
      packages/Longyi/Pay/Applepay/src/Http/routes.php
  24. 61 4
      packages/Longyi/Pay/Applepay/src/Payment/Applepay.php
  25. 12 3
      packages/Longyi/Pay/AwxAfterpay/src/Payment/AwxAfterpay.php
  26. 6 4
      packages/Longyi/Pay/AwxKlarna/src/Payment/AwxKlarna.php
  27. 12 0
      packages/Longyi/Pay/Klarna/src/Config/payment-methods.php
  28. 74 0
      packages/Longyi/Pay/Klarna/src/Config/system.php
  29. 12 0
      packages/Longyi/Pay/Klarna/src/Http/Controllers/Controller.php
  30. 72 0
      packages/Longyi/Pay/Klarna/src/Http/Controllers/KlarnaController.php
  31. 7 0
      packages/Longyi/Pay/Klarna/src/Http/routes.php
  32. 354 0
      packages/Longyi/Pay/Klarna/src/Payment/Klarna.php
  33. 41 0
      packages/Longyi/Pay/Klarna/src/Providers/KlarnaServiceProvider.php
  34. 20 0
      packages/Webkul/Admin/src/Config/acl.php
  35. 12 0
      packages/Webkul/Admin/src/Config/menu.php
  36. 9 0
      packages/Webkul/Admin/src/Config/system.php
  37. 162 0
      packages/Webkul/Admin/src/DataGrids/Catalog/ProductQuestionAnswerDataGrid.php
  38. 167 0
      packages/Webkul/Admin/src/DataGrids/Catalog/ProductQuestionDataGrid.php
  39. 84 0
      packages/Webkul/Admin/src/DataGrids/Reporting/SearchLogDataGrid.php
  40. 295 0
      packages/Webkul/Admin/src/Http/Controllers/Catalog/ProductQuestionController.php
  41. 54 0
      packages/Webkul/Admin/src/Http/Controllers/Reporting/SearchLogController.php
  42. 131 0
      packages/Webkul/Admin/src/Resources/lang/en/app.php
  43. 22 0
      packages/Webkul/Admin/src/Resources/lang/zh_CN/app.php
  44. 542 0
      packages/Webkul/Admin/src/Resources/views/catalog/questions/answers.blade.php
  45. 361 0
      packages/Webkul/Admin/src/Resources/views/catalog/questions/index.blade.php
  46. 132 0
      packages/Webkul/Admin/src/Resources/views/reporting/search-logs/index.blade.php
  47. 35 0
      packages/Webkul/Admin/src/Routes/catalog-routes.php
  48. 7 0
      packages/Webkul/Admin/src/Routes/reporting-routes.php
  49. 62 0
      packages/Webkul/Admin/tests/Feature/Reporting/SearchLogReportTest.php
  50. 87 3
      packages/Webkul/BagistoApi/src/Dto/CartData.php
  51. 8 0
      packages/Webkul/BagistoApi/src/Dto/CheckoutAddressOutput.php
  52. 34 0
      packages/Webkul/BagistoApi/src/Dto/CreateProductQuestionAnswerInput.php
  53. 34 0
      packages/Webkul/BagistoApi/src/Dto/CreateProductQuestionInput.php
  54. 5 0
      packages/Webkul/BagistoApi/src/Dto/PaymentCallbackInput.php
  55. 20 0
      packages/Webkul/BagistoApi/src/Dto/ProductSearch/ProductSearchEdgeDto.php
  56. 17 0
      packages/Webkul/BagistoApi/src/Dto/ProductSearch/ProductSearchPageInfoDto.php
  57. 28 0
      packages/Webkul/BagistoApi/src/Dto/ProductSearch/SearchMetadataDto.php
  58. 10 0
      packages/Webkul/BagistoApi/src/Dto/SaveCheckoutCartInput.php
  59. 9 1
      packages/Webkul/BagistoApi/src/Exception/AuthenticationException.php
  60. 10 1
      packages/Webkul/BagistoApi/src/Exception/InvalidInputException.php
  61. 9 1
      packages/Webkul/BagistoApi/src/Exception/ResourceNotFoundException.php
  62. 46 0
      packages/Webkul/BagistoApi/src/GraphQl/Serializer/FixedSerializerContextBuilder.php
  63. 45 0
      packages/Webkul/BagistoApi/src/Http/Middleware/SearchMetadataResponse.php
  64. 10 6
      packages/Webkul/BagistoApi/src/Jobs/ReconcilePendingPaymentJob.php
  65. 2 0
      packages/Webkul/BagistoApi/src/Models/PaymentAttempt.php
  66. 16 0
      packages/Webkul/BagistoApi/src/Models/Product.php
  67. 265 0
      packages/Webkul/BagistoApi/src/Models/ProductQuestion.php
  68. 305 0
      packages/Webkul/BagistoApi/src/Models/ProductQuestionAnswer.php
  69. 47 0
      packages/Webkul/BagistoApi/src/Models/ProductQuestionAnswerVote.php
  70. 74 0
      packages/Webkul/BagistoApi/src/Models/ProductSearch.php
  71. 56 0
      packages/Webkul/BagistoApi/src/Models/ReadCart.php
  72. 34 1
      packages/Webkul/BagistoApi/src/Providers/BagistoApiServiceProvider.php
  73. 11 0
      packages/Webkul/BagistoApi/src/Resolver/Factory/ProductRelationResolverFactory.php
  74. 96 0
      packages/Webkul/BagistoApi/src/Resolver/ProductSearchResolver.php
  75. 28 0
      packages/Webkul/BagistoApi/src/Resources/config/serializer/CartData.yaml
  76. 147 19
      packages/Webkul/BagistoApi/src/Services/PaymentService.php
  77. 43 45
      packages/Webkul/BagistoApi/src/State/CartTokenProcessor.php
  78. 2 0
      packages/Webkul/BagistoApi/src/State/CheckoutAddressProvider.php
  79. 4 0
      packages/Webkul/BagistoApi/src/State/CheckoutProcessor.php
  80. 100 60
      packages/Webkul/BagistoApi/src/State/ProductGraphQLProvider.php
  81. 227 0
      packages/Webkul/BagistoApi/src/State/ProductQuestionAnswerProcessor.php
  82. 87 0
      packages/Webkul/BagistoApi/src/State/ProductQuestionAnswerProvider.php
  83. 151 0
      packages/Webkul/BagistoApi/src/State/ProductQuestionProcessor.php
  84. 161 0
      packages/Webkul/BagistoApi/src/State/ProductQuestionProvider.php
  85. 6 1
      packages/Webkul/BagistoApi/src/State/ProductRestProvider.php
  86. 64 0
      packages/Webkul/BagistoApi/src/State/SaveCheckoutCartProcessor.php
  87. 141 0
      packages/Webkul/BagistoApi/tests/Feature/GraphQL/MergeCartTest.php
  88. 194 0
      packages/Webkul/BagistoApi/tests/Feature/ProductSearchGraphQLTest.php
  89. 66 0
      packages/Webkul/BagistoApi/tests/Unit/Http/Middleware/SearchMetadataResponseTest.php
  90. 97 0
      packages/Webkul/BagistoApi/tests/Unit/Payments/PaypalWebhookControllerTest.php
  91. 167 0
      packages/Webkul/BagistoApi/tests/Unit/Payments/PaypalWebhookServiceTest.php
  92. 111 0
      packages/Webkul/BagistoApi/tests/Unit/Payments/PaypalWebhookVerifierTest.php
  93. 133 0
      packages/Webkul/BagistoApi/tests/Unit/Repositories/ElasticSearchRepositoryTest.php
  94. 92 0
      packages/Webkul/BagistoApi/tests/Unit/Services/SearchLogServiceTest.php
  95. 4 0
      packages/Webkul/Checkout/src/Cart.php
  96. 5 0
      packages/Webkul/Marketing/src/Contracts/SearchLog.php
  97. 30 0
      packages/Webkul/Marketing/src/Database/Migrations/2026_07_16_000000_create_search_log_table.php
  98. 25 0
      packages/Webkul/Marketing/src/Models/SearchLog.php
  99. 7 0
      packages/Webkul/Marketing/src/Models/SearchLogProxy.php
  100. 0 0
      packages/Webkul/Marketing/src/Providers/ModuleServiceProvider.php

+ 384 - 0
app/Console/Commands/MigrateAsteriaReviews.php

@@ -0,0 +1,384 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Jobs\MigrateReviewJob;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Schema;
+
+/**
+ * Migrates product reviews from Asteria (Magento 1.x) into this Bagisto store.
+ *
+ * Prerequisites
+ * ─────────────
+ * 1. Add the `asteria` database connection to config/database.php (see the
+ *    comment block at the bottom of that file or the README).
+ * 2. Add the `migrated_from_asteria_id` column to product_reviews:
+ *
+ *      php artisan migrate  (after creating the migration below)
+ *
+ *    Or run this one-off SQL directly if you prefer:
+ *
+ *      ALTER TABLE product_reviews
+ *        ADD COLUMN migrated_from_asteria_id BIGINT UNSIGNED NULL DEFAULT NULL,
+ *        ADD UNIQUE INDEX uq_migrated_asteria_id (migrated_from_asteria_id);
+ *
+ * 3. Ensure a queue worker is running:
+ *
+ *      php artisan queue:work --queue=review-migration
+ *
+ * Usage
+ * ─────
+ * php artisan reviews:migrate-asteria
+ * php artisan reviews:migrate-asteria --batch-size=200
+ * php artisan reviews:migrate-asteria --status=1          # approved only
+ * php artisan reviews:migrate-asteria --reset-progress    # start from scratch
+ * php artisan reviews:migrate-asteria --dry-run           # dispatch nothing, just show stats
+ * php artisan reviews:migrate-asteria --sync              # run synchronously (no queue)
+ */
+class MigrateAsteriaReviews extends Command
+{
+    protected $signature = 'reviews:migrate-asteria
+        {--batch-size=100     : Number of reviews per queue job}
+        {--status=            : Filter by Magento status_id (1=approved,2=pending,3=not-approved)}
+        {--reset-progress     : Ignore saved progress and start from review_id=0}
+        {--dry-run            : Count records without dispatching any jobs}
+        {--sync               : Process synchronously instead of via the queue}
+        {--connection=asteria : Name of the Laravel DB connection for the Asteria database}';
+
+    protected $description = 'Migrate Asteria (Magento 1.x) product reviews into Bagisto asynchronously';
+
+    /** Cache key used to store the last successfully enqueued review_id. */
+    private const PROGRESS_KEY = 'migrate_asteria_reviews_last_id';
+
+    /** Default queue name for the migration jobs. */
+    private const QUEUE_NAME = 'review-migration';
+
+    public function handle(): int
+    {
+        $connection  = (string) $this->option('connection');
+        $batchSize   = max(1, (int) $this->option('batch-size'));
+        $statusId    = $this->option('status') !== null ? (int) $this->option('status') : null;
+        $resetProgress = (bool) $this->option('reset-progress');
+        $dryRun      = (bool) $this->option('dry-run');
+        $sync        = (bool) $this->option('sync');
+
+        // ── Pre-flight: make sure the connection and source tables exist ─────────
+        try {
+            DB::connection($connection)->getPdo();
+        } catch (\Throwable $e) {
+            $this->error("Cannot connect to Asteria DB (connection='{$connection}'): ".$e->getMessage());
+            $this->warn('Add an "asteria" entry to config/database.php (see the comment at the end of that file).');
+
+            return self::FAILURE;
+        }
+
+        if (! $this->ensureMigrationColumnExists()) {
+            return self::FAILURE;
+        }
+
+        // ── Build product and customer lookup maps ────────────────────────────────
+        $this->info('Building product SKU map…');
+        $productIdMap = $this->buildProductIdMap($connection);
+        $this->line('  '.count($productIdMap).' Magento products matched to Bagisto products by SKU.');
+
+        if (empty($productIdMap)) {
+            $this->warn('No matching products found. Are the SKUs consistent between the two stores?');
+        }
+
+        $this->info('Building customer e-mail map…');
+        $customerEmailMap = $this->buildCustomerEmailMap($connection);
+        $this->line('  '.count($customerEmailMap).' Magento customers matched to Bagisto customers by e-mail.');
+
+        // ── Determine starting review_id ──────────────────────────────────────────
+        $lastId = $resetProgress ? 0 : (int) Cache::get(self::PROGRESS_KEY, 0);
+
+        if ($lastId > 0) {
+            $this->line("Resuming from Asteria review_id > {$lastId} (use --reset-progress to restart).");
+        }
+
+        // ── Dispatch batches ──────────────────────────────────────────────────────
+        $totalDispatched = 0;
+        $totalSkipped    = 0;
+        $batchNumber     = 0;
+
+        $this->info($dryRun ? '[DRY RUN] Scanning reviews…' : 'Dispatching migration jobs…');
+
+        do {
+            $rows = $this->fetchBatch($connection, $lastId, $batchSize, $statusId);
+
+            if ($rows->isEmpty()) {
+                break;
+            }
+
+            $batchNumber++;
+            $batchRows = $rows->toArray();
+
+            // Collect unique Magento product IDs in this batch, filter unknown ones.
+            $unknownProducts = array_filter(
+                array_unique(array_column($batchRows, 'magento_product_id')),
+                fn ($id) => ! isset($productIdMap[$id])
+            );
+
+            if (! empty($unknownProducts)) {
+                $this->warn("  Batch #{$batchNumber}: ".count($unknownProducts).
+                    ' Magento product(s) not found in Bagisto: '.implode(', ', array_slice($unknownProducts, 0, 10)));
+                $totalSkipped += count(array_filter($batchRows, fn ($r) => ! isset($productIdMap[$r->magento_product_id])));
+            }
+
+            $lastId = max(array_column($batchRows, 'review_id'));
+
+            if (! $dryRun) {
+                if ($sync) {
+                    (new MigrateReviewJob(
+                        array_map(fn ($r) => (array) $r, $batchRows),
+                        $productIdMap,
+                        $customerEmailMap,
+                    ))->handle();
+                } else {
+                    MigrateReviewJob::dispatch(
+                        array_map(fn ($r) => (array) $r, $batchRows),
+                        $productIdMap,
+                        $customerEmailMap,
+                    )->onQueue(self::QUEUE_NAME);
+                }
+
+                Cache::put(self::PROGRESS_KEY, $lastId, now()->addDays(30));
+            }
+
+            $totalDispatched += count($batchRows);
+
+            $this->line(sprintf(
+                '  Batch #%d: %d reviews (last review_id=%d)%s',
+                $batchNumber,
+                count($batchRows),
+                $lastId,
+                $dryRun ? ' [skipped – dry-run]' : ($sync ? ' [processed]' : ' [enqueued]'),
+            ));
+
+            Log::info('MigrateAsteriaReviews: batch '.$batchNumber.', '.count($batchRows).' rows, last_id='.$lastId);
+
+        } while ($rows->count() === $batchSize);
+
+        // ── Summary ───────────────────────────────────────────────────────────────
+        $this->newLine();
+        $this->info("Done. Batches: {$batchNumber}, reviews processed/enqueued: {$totalDispatched}, unmatched: {$totalSkipped}.");
+
+        if (! $dryRun && ! $sync) {
+            $this->line('Run  <comment>php artisan queue:work --queue=review-migration</comment>  to process the queue.');
+        }
+
+        return self::SUCCESS;
+    }
+
+    // ─────────────────────────────────────────────────────────────────────────────
+    // Private helpers
+    // ─────────────────────────────────────────────────────────────────────────────
+
+    /**
+     * Fetch one page of Magento reviews, enriched with rating and customer e-mail.
+     *
+     * Magento table names use the default prefix (none).  If your Magento
+     * installation uses a prefix (e.g. `mag_`), set ASTERIA_DB_PREFIX in .env and
+     * pass it via the `prefix` key in the database connection config.
+     *
+     * @return \Illuminate\Support\Collection<int, object>
+     */
+    private function fetchBatch(
+        string $connection,
+        int $lastId,
+        int $limit,
+        ?int $statusId
+    ): \Illuminate\Support\Collection {
+        $query = DB::connection($connection)
+            ->table('review AS r')
+            ->join('review_detail AS rd', 'rd.review_id', '=', 'r.review_id')
+            ->leftJoin('customer_entity AS ce', 'ce.entity_id', '=', 'rd.customer_id')
+            ->leftJoinSub(
+                // Average all rating dimensions for each review (Quality, Value, Price…)
+                DB::connection($connection)
+                    ->table('rating_option_vote')
+                    ->select('review_id', DB::raw('AVG(`value`) AS avg_rating'))
+                    ->groupBy('review_id'),
+                'rv',
+                'rv.review_id',
+                '=',
+                'r.review_id'
+            )
+            ->select([
+                'r.review_id',
+                'r.created_at',
+                'r.entity_pk_value AS magento_product_id',
+                'r.status_id',
+                'rd.title',
+                'rd.detail AS comment',
+                'rd.nickname AS name',
+                'rd.customer_id AS magento_customer_id',
+                'ce.email AS customer_email',
+                'rv.avg_rating',
+            ])
+            ->where('r.entity_id', 1)   // entity_id=1 means "product" in Magento
+            ->where('r.review_id', '>', $lastId)
+            ->orderBy('r.review_id')
+            ->limit($limit);
+
+        if ($statusId !== null) {
+            $query->where('r.status_id', $statusId);
+        }
+
+        $reviews = $query->get();
+
+        if ($reviews->isEmpty()) {
+            return $reviews;
+        }
+
+        // Attach image URLs from Senje_Review's review_media_image table (status_id=1 = approved).
+        $reviewIds = $reviews->pluck('review_id')->toArray();
+
+        $imageRows = DB::connection($connection)
+            ->table('review_media_image')
+            ->select('review_id', 'image_id', 'url')
+            ->whereIn('review_id', $reviewIds)
+            ->where('status_id', 1)
+            ->orderBy('review_id')
+            ->orderBy('image_id')
+            ->get()
+            ->groupBy('review_id');
+
+        return $reviews->map(function ($row) use ($imageRows) {
+            $row->images = $imageRows->get($row->review_id, collect())
+                ->pluck('url')
+                ->toArray();
+
+            return $row;
+        });
+    }
+
+    /**
+     * Build a map of  magento_product_id → bagisto_product_id  by joining
+     * Magento's catalog_product_entity.sku against Bagisto's products.sku.
+     *
+     * @return array<int, int>
+     */
+    private function buildProductIdMap(string $connection): array
+    {
+        // Fetch all Magento SKUs.
+        $magentoPairs = DB::connection($connection)
+            ->table('catalog_product_entity')
+            ->select('entity_id', 'sku')
+            ->get()
+            ->pluck('entity_id', 'sku')  // sku → magento_id
+            ->toArray();
+
+        if (empty($magentoPairs)) {
+            return [];
+        }
+
+        // Match against Bagisto's products table.
+        $skus = array_keys($magentoPairs);
+        $map  = [];
+
+        foreach (array_chunk($skus, 500) as $chunk) {
+            $rows = DB::table('products')
+                ->select('id', 'sku')
+                ->whereIn('sku', $chunk)
+                ->get();
+
+            foreach ($rows as $row) {
+                $magentoId = $magentoPairs[$row->sku] ?? null;
+
+                if ($magentoId !== null) {
+                    $map[(int) $magentoId] = (int) $row->id;
+                }
+            }
+        }
+
+        return $map;
+    }
+
+    /**
+     * Build a map of  lowercase_email → bagisto_customer_id.
+     *
+     * @return array<string, int>
+     */
+    private function buildCustomerEmailMap(string $connection): array
+    {
+        // Fetch Magento customer e-mails.
+        $magentoEmails = DB::connection($connection)
+            ->table('customer_entity')
+            ->select('email')
+            ->get()
+            ->pluck('email')
+            ->map(fn ($e) => strtolower((string) $e))
+            ->unique()
+            ->toArray();
+
+        if (empty($magentoEmails)) {
+            return [];
+        }
+
+        $map = [];
+
+        foreach (array_chunk($magentoEmails, 500) as $chunk) {
+            $rows = DB::table('customers')
+                ->select('id', 'email')
+                ->whereIn(DB::raw('LOWER(email)'), $chunk)
+                ->get();
+
+            foreach ($rows as $row) {
+                $map[strtolower($row->email)] = (int) $row->id;
+            }
+        }
+
+        return $map;
+    }
+
+    /**
+     * Ensure product_reviews has the deduplication column.
+     * Prints instructions and returns false if the column is absent and
+     * cannot be auto-created (requires ALTER TABLE permission).
+     */
+    private function ensureMigrationColumnExists(): bool
+    {
+        $hasColumn = Schema::hasColumn('product_reviews', 'migrated_from_asteria_id');
+
+        if ($hasColumn) {
+            return true;
+        }
+
+        $this->warn('Column product_reviews.migrated_from_asteria_id is missing.');
+        $this->line('Run the following migration or SQL:');
+        $this->line('');
+        $this->line('  <comment>php artisan make:migration add_migrated_from_asteria_id_to_product_reviews</comment>');
+        $this->line('  Then add to the up() method:');
+        $this->line('  <comment>$table->bigInteger(\'migrated_from_asteria_id\')->unsigned()->nullable()->unique();</comment>');
+        $this->line('');
+        $this->line('  Or directly in MySQL:');
+        $this->line('  <comment>ALTER TABLE product_reviews');
+        $this->line('    ADD COLUMN migrated_from_asteria_id BIGINT UNSIGNED NULL DEFAULT NULL,');
+        $this->line('    ADD UNIQUE INDEX uq_migrated_asteria_id (migrated_from_asteria_id);</comment>');
+        $this->line('');
+
+        if (! $this->confirm('Attempt to add the column automatically now?', false)) {
+            return false;
+        }
+
+        try {
+            DB::statement('
+                ALTER TABLE product_reviews
+                    ADD COLUMN migrated_from_asteria_id BIGINT UNSIGNED NULL DEFAULT NULL,
+                    ADD UNIQUE INDEX uq_migrated_asteria_id (migrated_from_asteria_id)
+            ');
+            $this->info('Column added successfully.');
+
+            return true;
+        } catch (\Throwable $e) {
+            $this->error('Could not add column automatically: '.$e->getMessage());
+
+            return false;
+        }
+    }
+}

+ 153 - 0
app/Jobs/MigrateReviewJob.php

@@ -0,0 +1,153 @@
+<?php
+
+namespace App\Jobs;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+
+/**
+ * Processes a single batch of Magento reviews and writes them into the
+ * Bagisto product_reviews table.
+ *
+ * Each job receives an array of pre-fetched review rows so no remote DB
+ * connection is held across queue serialization/unserialization.
+ */
+class MigrateReviewJob implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    /** Retry up to 3 times before marking as failed. */
+    public int $tries = 3;
+
+    /** Allow 5 minutes per job for large batches. */
+    public int $timeout = 300;
+
+    /**
+     * @param  array<int, array<string, mixed>>  $reviews
+     *        Each element contains:
+     *          review_id, created_at, magento_product_id, status_id,
+     *          title, comment, name, customer_email, avg_rating
+     * @param  array<int, int>  $productIdMap   magento_product_id → bagisto product_id
+     * @param  array<string, int>  $customerEmailMap  email → bagisto customer_id
+     */
+    public function __construct(
+        private readonly array $reviews,
+        private readonly array $productIdMap,
+        private readonly array $customerEmailMap,
+    ) {}
+
+    public function handle(): void
+    {
+        $statusMap = [
+            1 => 'approved',
+            2 => 'pending',
+            3 => 'disapproved',
+        ];
+
+        $now = now();
+        $inserted = 0;
+        $skipped = 0;
+
+        foreach ($this->reviews as $row) {
+            $bagistoProductId = $this->productIdMap[$row['magento_product_id']] ?? null;
+
+            if ($bagistoProductId === null) {
+                Log::warning('MigrateReviewJob: no Bagisto product for Magento product_id='.$row['magento_product_id'].', review_id='.$row['review_id'].'. Skipping.');
+                $skipped++;
+                continue;
+            }
+
+            // Deduplicate: skip if already migrated (keyed on migrated_from_asteria_id).
+            $exists = DB::table('product_reviews')
+                ->where('migrated_from_asteria_id', $row['review_id'])
+                ->exists();
+
+            if ($exists) {
+                $skipped++;
+                continue;
+            }
+
+            $rating = $this->normaliseRating($row['avg_rating']);
+            $status = $statusMap[$row['status_id']] ?? 'pending';
+            $customerId = $row['customer_email']
+                ? ($this->customerEmailMap[strtolower($row['customer_email'])] ?? null)
+                : null;
+
+            $title   = $this->truncate((string) ($row['title'] ?? ''), 255) ?: '(no title)';
+            $comment = $this->truncate((string) ($row['comment'] ?? ''), 65535);
+            $name    = $this->truncate((string) ($row['name'] ?? ''), 255) ?: 'Guest';
+
+            DB::table('product_reviews')->insert([
+                'title'                   => $title,
+                'comment'                 => $comment,
+                'rating'                  => $rating,
+                'status'                  => $status,
+                'product_id'              => $bagistoProductId,
+                'customer_id'             => $customerId,
+                'name'                    => $name,
+                'migrated_from_asteria_id' => $row['review_id'],
+                'created_at'              => $row['created_at'] ?? $now,
+                'updated_at'              => $now,
+            ]);
+
+            $newReviewId = DB::getPdo()->lastInsertId();
+
+            // Download and store review images from review_media_image.url
+            if (! empty($row['images'])) {
+                $this->migrateImages((int) $newReviewId, $row['images']);
+            }
+
+            $inserted++;
+        }
+
+        Log::info("MigrateReviewJob: inserted={$inserted}, skipped={$skipped}");
+    }
+
+    /**
+     * Store image URLs from Asteria directly as attachment paths.
+     * Files will be uploaded to S3 in bulk separately.
+     *
+     * @param  int           $bagistoReviewId
+     * @param  list<string>  $urls
+     */
+    private function migrateImages(int $bagistoReviewId, array $urls): void
+    {
+        foreach ($urls as $url) {
+            if (empty($url)) {
+                continue;
+            }
+
+            DB::table('product_review_attachments')->insert([
+                'review_id' => $bagistoReviewId,
+                'type'      => 'image',
+                'mime_type' => null,
+                'path'      => $url,
+            ]);
+        }
+    }
+
+    /**
+     * Map Magento's average star value (1–5 float) to a Bagisto integer 1–5.
+     * Falls back to 5 when null/zero (treat missing rating as best-case).
+     */
+    private function normaliseRating(mixed $avgRating): int
+    {
+        if ($avgRating === null || $avgRating == 0) {
+            return 5;
+        }
+
+        return (int) max(1, min(5, round((float) $avgRating)));
+    }
+
+    private function truncate(string $value, int $maxLength): string
+    {
+        return mb_strlen($value) > $maxLength
+            ? mb_substr($value, 0, $maxLength)
+            : $value;
+    }
+}

+ 70 - 68
bootstrap/cache/services.php

@@ -65,40 +65,41 @@
     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',
+    64 => 'Longyi\\Pay\\Klarna\\Providers\\KlarnaServiceProvider',
+    65 => 'Webkul\\Attribute\\Providers\\AttributeServiceProvider',
+    66 => 'Webkul\\BookingProduct\\Providers\\BookingProductServiceProvider',
+    67 => 'Webkul\\CMS\\Providers\\CMSServiceProvider',
+    68 => 'Webkul\\CartRule\\Providers\\CartRuleServiceProvider',
+    69 => 'Webkul\\CatalogRule\\Providers\\CatalogRuleServiceProvider',
+    70 => 'Webkul\\Category\\Providers\\CategoryServiceProvider',
+    71 => 'Webkul\\Checkout\\Providers\\CheckoutServiceProvider',
+    72 => 'Webkul\\Core\\Providers\\CoreServiceProvider',
+    73 => 'Webkul\\Core\\Providers\\EnvValidatorServiceProvider',
+    74 => 'Webkul\\Customer\\Providers\\CustomerServiceProvider',
+    75 => 'Webkul\\DataGrid\\Providers\\DataGridServiceProvider',
+    76 => 'Webkul\\DataTransfer\\Providers\\DataTransferServiceProvider',
+    77 => 'Webkul\\DebugBar\\Providers\\DebugBarServiceProvider',
+    78 => 'Webkul\\FPC\\Providers\\FPCServiceProvider',
+    79 => 'Webkul\\GDPR\\Providers\\GDPRServiceProvider',
+    80 => 'Webkul\\Installer\\Providers\\InstallerServiceProvider',
+    81 => 'Webkul\\Inventory\\Providers\\InventoryServiceProvider',
+    82 => 'Webkul\\MagicAI\\Providers\\MagicAIServiceProvider',
+    83 => 'Webkul\\Marketing\\Providers\\MarketingServiceProvider',
+    84 => 'Webkul\\Notification\\Providers\\NotificationServiceProvider',
+    85 => 'Webkul\\Payment\\Providers\\PaymentServiceProvider',
+    86 => 'Webkul\\Paypal\\Providers\\PaypalServiceProvider',
+    87 => 'Webkul\\Product\\Providers\\ProductServiceProvider',
+    88 => 'Webkul\\Rule\\Providers\\RuleServiceProvider',
+    89 => 'Webkul\\Sales\\Providers\\SalesServiceProvider',
+    90 => 'Webkul\\Shipping\\Providers\\ShippingServiceProvider',
+    91 => 'Webkul\\Shop\\Providers\\ShopServiceProvider',
+    92 => 'Webkul\\Sitemap\\Providers\\SitemapServiceProvider',
+    93 => 'Webkul\\SocialLogin\\Providers\\SocialLoginServiceProvider',
+    94 => 'Webkul\\SocialShare\\Providers\\SocialShareServiceProvider',
+    95 => 'Webkul\\Tax\\Providers\\TaxServiceProvider',
+    96 => 'Webkul\\Theme\\Providers\\ThemeServiceProvider',
+    97 => 'Webkul\\User\\Providers\\UserServiceProvider',
+    98 => 'Webkul\\BagistoApi\\Providers\\BagistoApiServiceProvider',
   ),
   'eager' => 
   array (
@@ -149,40 +150,41 @@
     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',
+    47 => 'Longyi\\Pay\\Klarna\\Providers\\KlarnaServiceProvider',
+    48 => 'Webkul\\Attribute\\Providers\\AttributeServiceProvider',
+    49 => 'Webkul\\BookingProduct\\Providers\\BookingProductServiceProvider',
+    50 => 'Webkul\\CMS\\Providers\\CMSServiceProvider',
+    51 => 'Webkul\\CartRule\\Providers\\CartRuleServiceProvider',
+    52 => 'Webkul\\CatalogRule\\Providers\\CatalogRuleServiceProvider',
+    53 => 'Webkul\\Category\\Providers\\CategoryServiceProvider',
+    54 => 'Webkul\\Checkout\\Providers\\CheckoutServiceProvider',
+    55 => 'Webkul\\Core\\Providers\\CoreServiceProvider',
+    56 => 'Webkul\\Core\\Providers\\EnvValidatorServiceProvider',
+    57 => 'Webkul\\Customer\\Providers\\CustomerServiceProvider',
+    58 => 'Webkul\\DataGrid\\Providers\\DataGridServiceProvider',
+    59 => 'Webkul\\DataTransfer\\Providers\\DataTransferServiceProvider',
+    60 => 'Webkul\\DebugBar\\Providers\\DebugBarServiceProvider',
+    61 => 'Webkul\\FPC\\Providers\\FPCServiceProvider',
+    62 => 'Webkul\\GDPR\\Providers\\GDPRServiceProvider',
+    63 => 'Webkul\\Installer\\Providers\\InstallerServiceProvider',
+    64 => 'Webkul\\Inventory\\Providers\\InventoryServiceProvider',
+    65 => 'Webkul\\MagicAI\\Providers\\MagicAIServiceProvider',
+    66 => 'Webkul\\Marketing\\Providers\\MarketingServiceProvider',
+    67 => 'Webkul\\Notification\\Providers\\NotificationServiceProvider',
+    68 => 'Webkul\\Payment\\Providers\\PaymentServiceProvider',
+    69 => 'Webkul\\Paypal\\Providers\\PaypalServiceProvider',
+    70 => 'Webkul\\Product\\Providers\\ProductServiceProvider',
+    71 => 'Webkul\\Rule\\Providers\\RuleServiceProvider',
+    72 => 'Webkul\\Sales\\Providers\\SalesServiceProvider',
+    73 => 'Webkul\\Shipping\\Providers\\ShippingServiceProvider',
+    74 => 'Webkul\\Shop\\Providers\\ShopServiceProvider',
+    75 => 'Webkul\\Sitemap\\Providers\\SitemapServiceProvider',
+    76 => 'Webkul\\SocialLogin\\Providers\\SocialLoginServiceProvider',
+    77 => 'Webkul\\SocialShare\\Providers\\SocialShareServiceProvider',
+    78 => 'Webkul\\Tax\\Providers\\TaxServiceProvider',
+    79 => 'Webkul\\Theme\\Providers\\ThemeServiceProvider',
+    80 => 'Webkul\\User\\Providers\\UserServiceProvider',
+    81 => 'Webkul\\BagistoApi\\Providers\\BagistoApiServiceProvider',
   ),
   'deferred' => 
   array (

+ 1 - 0
bootstrap/providers.php

@@ -22,6 +22,7 @@ return [
     Longyi\Pay\Applepay\Providers\ApplepayServiceProvider::class,
     Longyi\Pay\Airwallex\Providers\AirwallexServiceProvider::class,
     Longyi\Pay\Afterpay\Providers\AfterpayServiceProvider::class,
+    Longyi\Pay\Klarna\Providers\KlarnaServiceProvider::class,
     Webkul\Attribute\Providers\AttributeServiceProvider::class,
     Webkul\BookingProduct\Providers\BookingProductServiceProvider::class,
     Webkul\CMS\Providers\CMSServiceProvider::class,

+ 1 - 0
composer.json

@@ -89,6 +89,7 @@
             "Longyi\\Pay\\AwxKlarna\\": "packages/Longyi/Pay/AwxKlarna/src/",
             "Longyi\\Pay\\AwxAfterpay\\": "packages/Longyi/Pay/AwxAfterpay/src/",
             "Longyi\\Pay\\Afterpay\\": "packages/Longyi/Pay/Afterpay/src/",
+            "Longyi\\Pay\\Klarna\\": "packages/Longyi/Pay/Klarna/src/",
             "Webkul\\Admin\\": "packages/Webkul/Admin/src",
             "Webkul\\Attribute\\": "packages/Webkul/Attribute/src",
             "Webkul\\BookingProduct\\": "packages/Webkul/BookingProduct/src",

+ 3 - 0
config/api-platform.php

@@ -45,6 +45,7 @@ return [
             'Webkul\BagistoApi\Http\Middleware\BagistoApiDocumentationMiddleware',
             'Webkul\BagistoApi\Http\Middleware\ForceApiJson',
             'Webkul\BagistoApi\Http\Middleware\PaginationHeaders',
+            'Webkul\BagistoApi\Http\Middleware\SearchMetadataResponse',
             'Webkul\BagistoApi\Http\Middleware\WrapApiResponse',
             'Spatie\ResponseCache\Middlewares\CacheResponse',
         ],
@@ -54,6 +55,7 @@ return [
         base_path('packages/Webkul/BagistoApi/src/Models/'),
         base_path('packages/Webkul/BagistoApi/src/Dto/ProductDetail/'),
         base_path('packages/Webkul/BagistoApi/src/Dto/CustomerOrder/'),
+        base_path('packages/Webkul/BagistoApi/src/Dto/ProductSearch/'),
     ],
 
     'formats' => [
@@ -112,6 +114,7 @@ return [
         'middleware' => [
             'Webkul\BagistoApi\Http\Middleware\SetLocaleChannel',
             'Webkul\BagistoApi\Http\Middleware\VerifyGraphQLStorefrontKey',
+            'Webkul\BagistoApi\Http\Middleware\SearchMetadataResponse',
         ],
     ],
 

+ 32 - 0
config/database.php

@@ -42,6 +42,38 @@ return [
             'synchronous'             => null,
         ],
 
+        /*
+         | ──────────────────────────────────────────────────────────────────────
+         | Asteria (Magento 1.x) source database – used by reviews:migrate-asteria
+         | ──────────────────────────────────────────────────────────────────────
+         | Add the following variables to your .env file:
+         |
+         |   ASTERIA_DB_HOST=127.0.0.1
+         |   ASTERIA_DB_PORT=3306
+         |   ASTERIA_DB_DATABASE=asteria_db
+         |   ASTERIA_DB_USERNAME=root
+         |   ASTERIA_DB_PASSWORD=
+         |   ASTERIA_DB_PREFIX=          # leave blank if Magento has no table prefix
+         |   ASTERIA_DB_CHARSET=utf8
+         |
+         | This connection is READ-ONLY at the application level; the migration
+         | command only executes SELECT queries against it.
+         */
+        'asteria' => [
+            'driver'      => 'mysql',
+            'host'        => env('ASTERIA_DB_HOST', '127.0.0.1'),
+            'port'        => env('ASTERIA_DB_PORT', '3306'),
+            'database'    => env('ASTERIA_DB_DATABASE', ''),
+            'username'    => env('ASTERIA_DB_USERNAME', 'root'),
+            'password'    => env('ASTERIA_DB_PASSWORD', ''),
+            'unix_socket' => env('ASTERIA_DB_SOCKET', ''),
+            'charset'     => env('ASTERIA_DB_CHARSET', 'utf8'),
+            'collation'   => env('ASTERIA_DB_COLLATION', 'utf8_unicode_ci'),
+            'prefix'      => env('ASTERIA_DB_PREFIX', ''),
+            'strict'      => false,
+            'engine'      => null,
+        ],
+
         'mysql' => [
             'driver'         => 'mysql',
             'url'            => env('DB_URL'),

+ 23 - 22
packages/Longyi/Gift/src/Http/Controllers/Shop/GiftController.php

@@ -67,32 +67,35 @@ class GiftController extends Controller
         return ApiResponse::success('Gift card added successfully');
     }
     /**
-     * Get customer's gift cards list
+     * Get customer's gift cards list (with pagination)
      */
-    public function getCustomerGiftCards(): JsonResponse
+    public function getCustomerGiftCards(Request $request): JsonResponse
     {
         try {
             $customerId = auth()->user()->id;
+            $perPage = (int) $request->input('per_page', 10);
 
-            $giftCards = GiftCards::where('customer_id', $customerId)
+            $paginator = GiftCards::where('customer_id', $customerId)
                 ->where('remaining_giftcard_amount', '>', 0)
                 ->where('expirationdate', '>=', now())
                 ->orderBy('created_at', 'desc')
-                ->get();
-            return ApiResponse::success(
-                $giftCards->map(function ($giftCard) {
-                    return [
-                        'id' => $giftCard->id,
-                        'giftcard_number' => $giftCard->giftcard_number,
-                        'giftcard_amount' => $giftCard->giftcard_amount,
-                        'remaining_giftcard_amount' => $giftCard->remaining_giftcard_amount,
-                        '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
-                    ];
-                })
-            );
+                ->paginate($perPage);
+
+            $data = $paginator->map(function ($giftCard) {
+                return [
+                    'id' => $giftCard->id,
+                    'giftcard_number' => $giftCard->giftcard_number,
+                    'giftcard_amount' => $giftCard->giftcard_amount,
+                    'remaining_giftcard_amount' => $giftCard->remaining_giftcard_amount,
+                    '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
+                ];
+            });
+
+            return ApiResponse::paginated($data, $paginator);
+
         } catch (\Exception $e) {
             return ApiResponse::error($e->getMessage());
         }
@@ -111,7 +114,7 @@ class GiftController extends Controller
                ->where('customer_id', $customerId)->first();
            $cart = Cart::getCart();
            if (!$giftCard) {
-               return ApiResponse::error('Coupon not found.');
+               return ApiResponse::error('giftcard not found.');
            }
            $remainingGiftcardAmount = core()->convertPrice($giftCard->remaining_giftcard_amount);
            if ($remainingGiftcardAmount <= 0) {
@@ -159,9 +162,7 @@ class GiftController extends Controller
         }
 
         // Return response with updated cart data
-        return ApiResponse::success([
-                'data'     => new CustomCartResource(Cart::getCart())
-            ], trans('gift::app.giftcard.remove'));
+        return ApiResponse::success(new CustomCartResource(Cart::getCart()), trans('gift::app.giftcard.remove'));
     }
 
     /**

+ 1 - 0
packages/Longyi/Gift/src/Models/GiftCards.php

@@ -74,6 +74,7 @@ class GiftCards extends Model implements GiftCardsContract
         // 清除购物车中的礼品卡标记
         $cart->giftcard_number = null;
         $cart->giftcard_amount = null;
+        $cart->base_giftcard_amount = null;
 
         // 恢复购物车总额
         $cart->grand_total += $giftcardAmount;

+ 71 - 0
packages/Longyi/Gift/src/Services/GiftCardService.php

@@ -0,0 +1,71 @@
+<?php
+
+namespace Longyi\Gift\Services;
+
+use Webkul\Checkout\Facades\Cart;
+use Longyi\Gift\Models\GiftCards;
+
+class GiftCardService
+{
+    /**
+     * 激活礼品卡
+     */
+    public function activate(string $code, ?int $customerId = null): void
+    {
+        $customerId = $customerId ?? auth()->user()?->id;
+
+        if (! $customerId) {
+            throw new \Exception('Customer not authenticated.');
+        }
+
+        $giftCard = GiftCards::where('giftcard_number', $code)
+            ->where('customer_id', $customerId)
+            ->first();
+
+        if (! $giftCard) {
+            throw new \Exception('Gift card not found.');
+        }
+
+        $cart = Cart::getCart();
+
+        if (!empty($cart->giftcard_amount)) {
+            throw new \Exception(
+                'The shopping cart has been paid with a gift card.'
+            );
+        }
+
+        GiftCards::setGiftCardCode($giftCard);
+
+        Cart::collectTotals();
+    }
+
+
+    /**
+     * 删除礼品卡
+     */
+    public function remove(?int $customerId = null): void
+    {
+        $customerId = $customerId ?? auth()->user()?->id;
+
+        $cart = Cart::getCart();
+
+        GiftCards::removeGiftCardCode();
+
+        Cart::collectTotals();
+
+        if ($cart->giftcard_number && $customerId) {
+            $giftCard = GiftCards::where(
+                'giftcard_number',
+                $cart->giftcard_number
+            )
+            ->where('customer_id', $customerId)
+            ->first();
+
+            if ($giftCard) {
+                $giftCard->used_giftcard_amount = 0;
+                $giftCard->remaining_giftcard_amount = $giftCard->giftcard_amount;
+                $giftCard->save();
+            }
+        }
+    }
+}

+ 1 - 1
packages/Longyi/Member/src/Listeners/MemberHandler.php

@@ -27,7 +27,7 @@ class MemberHandler
     public function applyMemberDiscount($cart)
     {
 
-        if (!$cart || !auth()->guard('customer')->check()) {
+        if (!$cart || !$cart->customer_id) {
             return;
         }
         if ($cart->vip_plus_amount <= 0) {

+ 6 - 2
packages/Longyi/Member/src/Listeners/VipDiscountHandler.php

@@ -3,6 +3,7 @@
 namespace Longyi\Member\Listeners;
 
 use Carbon\Carbon;
+use Webkul\Customer\Models\Customer;
 
 class VipDiscountHandler
 {
@@ -16,11 +17,14 @@ class VipDiscountHandler
      */
     public function applyVipDiscount($cart)
     {
-        if (!$cart || !auth()->guard('customer')->check()) {
+        if (!$cart || !$cart->customer_id) {
             return;
         }
         $isVip = false;
-        $customer = auth()->guard('customer')->user();
+        $customer = Customer::find($cart->customer_id);
+        if (!$customer) {
+            return;
+        }
         if ($cart->vip_plus_amount > 0 ||  $this->isValidVip($customer)) {
             $isVip = true;
         }

+ 53 - 0
packages/Longyi/Member/src/Services/MemberDiscountService.php

@@ -0,0 +1,53 @@
+<?php
+
+namespace Longyi\Member\Services;
+
+use Webkul\Checkout\Facades\Cart;
+
+class MemberDiscountService
+{
+    /**
+     * Member discount amount.
+     */
+    protected const MEMBER_DISCOUNT_AMOUNT = 19.9;
+
+    /**
+     * Apply member discount to cart.
+     */
+    public function apply(): void
+    {
+        $cart = Cart::getCart();
+
+        if (! $cart) {
+            throw new \Exception('Shopping cart not found.');
+        }
+
+        if ($cart->vip_plus_amount && $cart->vip_plus_amount > 0) {
+            throw new \Exception('Member discount has already been applied.');
+        }
+
+        $discountAmount = core()->convertPrice(self::MEMBER_DISCOUNT_AMOUNT);
+
+        $cart->vip_plus_amount = $discountAmount;
+        $cart->base_vip_plus_amount = core()->convertToBasePrice($discountAmount);
+        $cart->save();
+        Cart::collectTotals();
+    }
+
+    /**
+     * Remove member discount from cart.
+     */
+    public function remove(): void
+    {
+        $cart = Cart::getCart();
+
+        if (! $cart || ! $cart->vip_plus_amount || $cart->vip_plus_amount <= 0) {
+            return;
+        }
+
+        $cart->vip_plus_amount = null;
+        $cart->base_vip_plus_amount = null;
+        $cart->save();
+        Cart::collectTotals();
+    }
+}

+ 36 - 6
packages/Longyi/Pay/Afterpay/src/Config/system.php

@@ -38,16 +38,32 @@ return [
                 'channel_based' => false,
                 'locale_based' => true,
             ], [
-                'name' => 'client_id',
-                'title' => 'Merchant ID',
+                'name' => 'client_id_us',
+                'title' => 'Merchant ID (US)',
                 'type' => 'text',
                 'depend' => 'active:1',
                 'validation' => 'required_if:active,1',
                 'channel_based' => false,
                 'locale_based' => true,
             ], [
-                'name' => 'secret_id',
-                'title' => 'Merchant Key',
+                'name' => 'secret_id_us',
+                'title' => 'Merchant Key (US)',
+                'type' => 'password',
+                'depend' => 'active:1',
+                'validation' => 'required_if:active,1',
+                'channel_based' => false,
+                'locale_based' => true,
+            ], [
+                'name' => 'client_id_ca',
+                'title' => 'Merchant ID (CA)',
+                'type' => 'text',
+                'depend' => 'active:1',
+                'validation' => 'required_if:active,1',
+                'channel_based' => false,
+                'locale_based' => true,
+            ], [
+                'name' => 'secret_id_ca',
+                'title' => 'Merchant Key (CA)',
                 'type' => 'password',
                 'depend' => 'active:1',
                 'validation' => 'required_if:active,1',
@@ -63,14 +79,28 @@ return [
                 'default'       => 5,
             ], [
                 'name'          => 'min_order_total',
-                'title'         => 'Minimum Order Total',
+                'title'         => 'Minimum Order Total (US)',
                 'type'          => 'blade',
                 'path'          => 'Afterpay::admin.system.readonly-value',
                 'channel_based' => false,
                 'locale_based'  => false,
             ], [
                 'name'          => 'max_order_total',
-                'title'         => 'Maximum Order Total',
+                'title'         => 'Maximum Order Total (US)',
+                'type'          => 'blade',
+                'path'          => 'Afterpay::admin.system.readonly-value',
+                'channel_based' => false,
+                'locale_based'  => false,
+            ], [
+                'name'          => 'min_order_total_ca',
+                'title'         => 'Minimum Order Total (CA)',
+                'type'          => 'blade',
+                'path'          => 'Afterpay::admin.system.readonly-value',
+                'channel_based' => false,
+                'locale_based'  => false,
+            ], [
+                'name'          => 'max_order_total_ca',
+                'title'         => 'Maximum Order Total (CA)',
                 'type'          => 'blade',
                 'path'          => 'Afterpay::admin.system.readonly-value',
                 'channel_based' => false,

+ 29 - 13
packages/Longyi/Pay/Afterpay/src/Http/Controllers/AfterpayController.php

@@ -44,9 +44,21 @@ class AfterpayController extends Controller
         $code = $request->input('code', 'afterpay');
 
         try {
-            $result = $this->$code->fetchConfiguration($code);
+            // afterpay 支持多国家,需要分别查询各国限额
+            $countries = ($code === 'afterpay')
+                ? ['US' => '', 'CA' => '_ca']   // US 后缀为空保持兼容,CA 后缀 _ca
+                : ['GBP' => ''];                  // clearpay 只查一次
 
-            if (! $result) {
+            $results = [];
+            foreach ($countries as $countryCode => $keySuffix) {
+                $result = $this->$code->fetchConfiguration($code, $countryCode);
+                if (! $result) {
+                    continue;
+                }
+                $results[$keySuffix] = $result;
+            }
+
+            if (empty($results)) {
                 return response()->json([
                     'success' => false,
                     'message' => 'Failed to fetch configuration from Afterpay',
@@ -55,16 +67,18 @@ class AfterpayController extends Controller
 
             // 写入数据库
             $prefix = 'sales.payment_methods.' . $code . '.';
-            $fields = [
-                'min_order_total' => $result['min_order_total'] ?? '',
-                'max_order_total' => $result['max_order_total'] ?? '',
-            ];
+            foreach ($results as $keySuffix => $result) {
+                $fields = [
+                    'min_order_total' . $keySuffix => $result['min_order_total'] ?? '',
+                    'max_order_total' . $keySuffix => $result['max_order_total'] ?? '',
+                ];
 
-            foreach ($fields as $field => $value) {
-                \Webkul\Core\Models\CoreConfig::updateOrCreate(
-                    ['code' => $prefix . $field],
-                    ['value' => $value]
-                );
+                foreach ($fields as $field => $value) {
+                    \Webkul\Core\Models\CoreConfig::updateOrCreate(
+                        ['code' => $prefix . $field],
+                        ['value' => $value]
+                    );
+                }
             }
 
             // 清除配置缓存
@@ -74,8 +88,10 @@ class AfterpayController extends Controller
             return response()->json([
                 'success'         => true,
                 'message'         => 'Limits updated successfully',
-                'min_order_total' => $result['min_order_total'] ?? '',
-                'max_order_total' => $result['max_order_total'] ?? '',
+                'min_order_total' => $results['']['min_order_total'] ?? '',
+                'max_order_total' => $results['']['max_order_total'] ?? '',
+                'min_order_total_ca' => $results['_ca']['min_order_total'] ?? '',
+                'max_order_total_ca' => $results['_ca']['max_order_total'] ?? '',
             ]);
         } catch (\Exception $e) {
             \Log::channel('payment')->error("{$code} updateLimits error: " . $e->getMessage());

+ 116 - 10
packages/Longyi/Pay/Afterpay/src/Payment/Afterpay.php

@@ -21,6 +21,7 @@ class Afterpay extends Payment
     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';
+    protected $orderStatusUlr = 'https://global-api-sandbox.afterpay.com/v2/payments/token:';
 
     public function __construct()
     {
@@ -28,12 +29,43 @@ class Afterpay extends Payment
             $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->orderStatusUlr = 'https://global-api-sandbox.afterpay.com/v2/payments/token:';
         }
-        $this->clientId = $this->getConfigData('client_id');
-        $this->apikey = $this->getConfigData('secret_id');
+        $this->clientId = $this->getConfigData('client_id_us');
+        $this->apikey = $this->getConfigData('secret_id_us');
         $this->secret = $this->getConfigData('webhook_id');
     }
 
+    /**
+     * 根据国家代码动态解析并设置 Afterpay 凭证
+     * 美国用 US 凭证,加拿大用 CA 凭证
+     */
+    protected function resolveCredentials(?string $countryCode = null): void
+    {
+        if ($countryCode === 'CA') {
+            // 使用加拿大凭证 (CA)
+            $this->clientId = $this->getConfigData('client_id_ca');
+            $this->apikey = $this->getConfigData('secret_id_ca');
+        } elseif($countryCode === 'GBP') {
+            // 使用英国凭证 (GBP)
+            $this->clientId = $this->getConfigData('client_id');
+            $this->apikey = $this->getConfigData('secret_id');
+        } else {
+            // 默认使用美国凭证 (US)
+            $this->clientId = $this->getConfigData('client_id_us');
+            $this->apikey = $this->getConfigData('secret_id_us');
+        }
+    }
+
+    /**
+     * 从当前订单或购物车获取国家代码
+     */
+    protected function getCurrentCountryCode(): ?string
+    {
+        $info = $this->getOrder() ?: Cart::getCart();
+        return $info->billing_address->country ?? null;
+    }
+
     protected $code  = 'afterpay';
 
     public function getRedirectUrl()
@@ -75,6 +107,10 @@ class Afterpay extends Payment
 
     public function createPayment($order, $override, $input = null)
     {
+        // 根据订单 billing 国家解析对应凭证
+        $countryCode = $order->billing_address->country ?? null;
+        $this->resolveCredentials($countryCode);
+
         $shop = $order->shipping_address;
         $bill = $order->billing_address;
         $telephone = $shop->phone ?: $bill->phone;
@@ -143,7 +179,8 @@ class Afterpay extends Payment
         Log::channel('payment')->info('Afterpay createPayment response', [
             'order_id'  => $order->increment_id ?? null,
             'status'    => $response->getStatusCode(),
-            'body'      => $responseBody,
+            'body'      => json_decode($responseBody, true),
+            'data'      => $data
         ]);
 
         $resultObject = json_decode($responseBody);
@@ -154,7 +191,7 @@ class Afterpay extends Payment
         }
     }
 
-    public function capture($token)
+    public function capture($token, $methodType = 'Afterpay')
     {
         $client = new Client();
         $response = $client->request('POST', $this->capturePaymentUlr, [
@@ -168,17 +205,17 @@ class Afterpay extends Payment
             'verify' => false
         ]);
         $responseBody = $response->getBody()->getContents();
-        Log::channel('payment')->info('Afterpay capture response', [
+        Log::channel('payment')->info("{$methodType} capture response", [
             'token'     => $token,
             'status'    => $response->getStatusCode(),
-            'body'      => $responseBody,
+            'body'      => json_decode($responseBody, true),
         ]);
 
         $resultObject = json_decode($responseBody);
-        if ($resultObject->status == 'APPROVED') {
+        if ($resultObject->status == 'APPROVED' && $resultObject->paymentState == 'CAPTURED') {
             return ['transaction_id' => $resultObject->token, 'amount' => $resultObject->events[0]->amount->amount];
         } else {
-            throw new \Exception('afterpay capture is error');
+            throw new \Exception("{$methodType} capture is error");
         }
     }
 
@@ -187,6 +224,10 @@ class Afterpay extends Payment
         if ($order->status != Order::STATUS_PENDING) {
             return new \Exception('order status is error');
         }
+        // 根据订单 billing 国家解析对应凭证
+        $countryCode = $order->billing_address->country ?? null;
+        $this->resolveCredentials($countryCode);
+
         $additional = $order->payment->additional;
         $token = $additional['gateway_order_id'];
         if ($transactionId != $token) {
@@ -221,9 +262,12 @@ class Afterpay extends Payment
 
 
 
-    public function fetchConfiguration(string $code = 'afterpay'): ?array
+    public function fetchConfiguration(string $code = 'afterpay', ?string $countryCode = null): ?array
     {
         try {
+            // 根据国家代码解析对应凭证
+            $this->resolveCredentials($countryCode);
+
             $client = new Client();
             $response = $client->request('GET', $this->configurationUlr, [
                 'headers' => [
@@ -254,6 +298,57 @@ class Afterpay extends Payment
         }
     }
 
+    public function fetchOrderStatus($gatewayOrderId = null)
+    {
+        $order = $this->getOrder();
+        if ($order) {
+            $gatewayOrderId = $order->payment->additional['gateway_order_id'];
+        }
+        if (empty($gatewayOrderId)) {
+            return new \Exception('gatewayOrderId is empty');
+        }
+        $countryCode = $order->billing_address->country ?? null;
+        $this->resolveCredentials($countryCode);
+        $paymentStatus = [];
+        $paymentStatus['captures'] = null;
+        $paymentStatus['status'] = null;
+        try {
+            $client = new Client();
+            $response = $client->request('GET', $this->orderStatusUlr . $gatewayOrderId, [
+                '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("{$order->payment->method} fetchOrderStatus response", [
+                'gatewayOrderId' => $gatewayOrderId,
+                'status' => $response->getStatusCode(),
+                'body' => $responseBody
+            ]);
+            $resultObject = json_decode($responseBody);
+            if ($resultObject->status == 'APPROVED' && $resultObject->paymentState == 'CAPTURED') {
+                $paymentStatus['captures'] = true;
+                $paymentStatus['status'] = 'COMPLETED';
+            }
+        } catch (\Exception $e) {
+            Log::channel('payment')->error("{$order->payment->method} fetchOrderStatus error: " . $e->getMessage());
+            try {
+                // 如果获取失败,尝试进行一次确认
+                $captureArr = $this->capture($gatewayOrderId);
+                $paymentStatus['captures'] = $captureArr['transaction_id'] ? true : null;
+                $paymentStatus['status'] = $captureArr['transaction_id'] ? 'COMPLETED' : null;
+            } catch (\Exception $e) {
+                $paymentStatus['captures'] = null;
+                $paymentStatus['status'] = null;
+            }
+        }
+        return $paymentStatus;
+    }
+
     public function isAvailable($isShow = false)
     {
         if (! parent::isAvailable()) {
@@ -268,8 +363,11 @@ class Afterpay extends Payment
             if (! in_array($billingCountry, $allowedCountries)) {
                 return false;
             }
+            $currency = $info->order_currency_code ?? $info->cart_currency_code ?? false;
+            if ($currency != 'CAD' && $currency != 'USD') {
+                return false;
+            }
         }
-
         // 检查订单金额是否在限额范围内
 //        if ($info) {
 //            $grandTotal = $info->grand_total ?? $info->sub_total ?? 0;
@@ -291,4 +389,12 @@ class Afterpay extends Payment
     {
         return round((float) $number, 2);
     }
+    public function getDescription()
+    {
+        $description = $this->getConfigData('description');
+        $info = $this->getOrder() ?: Cart::getCart();
+        $amount = round($info->grand_total / 4, 2);
+        $symbol = core()->getCurrentCurrency()->symbol;
+        return sprintf($description, $symbol.$amount);
+    }
 }

+ 21 - 11
packages/Longyi/Pay/Afterpay/src/Payment/Clearpay.php

@@ -107,7 +107,8 @@ class Clearpay extends Afterpay
         Log::channel('payment')->info('Clearpay createPayment response', [
             'order_id'  => $order->increment_id ?? null,
             'status'    => $response->getStatusCode(),
-            'body'      => $responseBody,
+            'body'      => json_decode($responseBody, true),
+            'data'      => $data
         ]);
 
         $resultObject = json_decode($responseBody);
@@ -117,7 +118,18 @@ class Clearpay extends Afterpay
             return $resultObject->token;
         }
     }
-
+    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, 'Clearpay');
+    }
     public function getCountryOptions(): array
     {
         return core()->countries()->map(function ($country) {
@@ -133,15 +145,13 @@ class Clearpay extends Afterpay
             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);
+        if ($info) {
+            $billingCountry = $info->billing_address->country ?? false;
+            $currency = $info->order_currency_code ?? $info->cart_currency_code ?? false;
+            // 仅当国家为 GB 且货币为 GBP 时才显示 Clearpay
+            if ($billingCountry === 'GB' && $currency === 'GBP') {
+                return true;
+            }
         }
         return false;
     }

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

@@ -52,8 +52,12 @@
                 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"]');
+                    var minCaEl = section.querySelector('.afterpay-limit-value[data-field="min_order_total_ca"]');
+                    var maxCaEl = section.querySelector('.afterpay-limit-value[data-field="max_order_total_ca"]');
                     if (minEl) minEl.textContent = data.min_order_total || '-';
                     if (maxEl) maxEl.textContent = data.max_order_total || '-';
+                    if (minCaEl) minCaEl.textContent = data.min_order_total_ca || '-';
+                    if (maxCaEl) maxCaEl.textContent = data.max_order_total_ca || '-';
                     msgEl.textContent = data.message;
                     msgEl.className = 'text-sm text-green-600';
                 } else {

+ 25 - 4
packages/Longyi/Pay/Airwallex/src/Http/Controllers/AirwallexController.php

@@ -2,6 +2,7 @@
 
 namespace Longyi\Pay\Airwallex\Http\Controllers;
 
+use Illuminate\Support\Facades\Log;
 use Longyi\Pay\Airwallex\Payment\Airwallex;
 use Webkul\Checkout\Facades\Cart;
 use Webkul\Sales\Models\Order;
@@ -24,13 +25,19 @@ class AirwallexController extends Controller
 
     public function hook()
     {
-        $param = @file_get_contents('php://input');
+        $param = request()->getContent();
         $header = $this->getAllHeaders();
         $data = [
-            'timestamp' => $header['x-timestamp'],
-            'signature' => $header['x-signature'],
+            'timestamp' => $header['x-timestamp'] ?? '',
+            'signature' => $header['x-signature'] ?? '',
             'content' => $param
         ];
+        //记录日志
+        Log::channel('payment')->info('airwallex hook request received', [
+            'query'     => request()->query(),
+            'header'  => $header,
+            'raw_body'  => json_decode($param, true),
+        ]);
         $result = $this->airwallex->checkSignature($data, 'secret');
         if (!$result) {
             @header("HTTP/1.0 400 Bad Request");
@@ -61,6 +68,20 @@ class AirwallexController extends Controller
                 @header("HTTP/1.0 200 ok");
                 break;
         }
+        echo 'success';exit;
+    }
+    public function getAllHeaders()
+    {
+        $headers = array();
+        foreach ($_SERVER as $key => $value) {
+            if (substr($key, 0, 5) === 'HTTP_') {
+                $key = substr($key, 5);
+                $key = str_replace('_', ' ', $key);
+                $key = str_replace(' ', '-', $key);
+                $key = strtolower($key);
+                $headers[$key] = $value;
+            }
+        }
+        return $headers;
     }
-
 }

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

@@ -4,4 +4,4 @@ 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');
+Route::any('airwallex/webhook/hook', [AirwallexController::class, 'hook'])->name('airwallex.webhook.hook');

+ 9 - 3
packages/Longyi/Pay/Airwallex/src/Payment/Airwallex.php

@@ -23,7 +23,7 @@ class Airwallex extends Payment
     protected $apikey;
     protected $secret;
     protected $currentOrder;
-    protected $prefix = 'AS';
+    protected $prefix = 'QQS';
     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';
@@ -132,7 +132,8 @@ class Airwallex extends Payment
             'paymentIntentId'  => $paymentIntentId ?? null,
             'order'  => $order->increment_id ?? null,
             'status'    => $response->getStatusCode(),
-            'body'      => $responseBody,
+            'body'      => json_decode($responseBody, true),
+            'data'      => $data
         ]);
         $resultObject = json_decode($responseBody);
         if (empty($resultObject->id)) {
@@ -169,8 +170,8 @@ class Airwallex extends Payment
     //成功修改状态
     public function paymentSucceeded($orderId, $transactionId)
     {
-        $order = $this->orderRepository->find($orderId);
 
+        $order = $this->orderRepository->findOneByField('increment_id', $orderId);
         if (!$order->id) {
             return false;
         }
@@ -179,6 +180,7 @@ class Airwallex extends Payment
         }
         $order->status = Order::STATUS_PROCESSING;
         $order->save();
+
         if ($order->payment) {
             $additional = $order->payment->additional;
             $additional['transaction_id'] = $transactionId;
@@ -188,6 +190,10 @@ class Airwallex extends Payment
         if ($order->canInvoice()) {
             $this->invoiceRepository->create($this->prepareInvoiceData($order));
         }
+        Log::channel('payment')->info('airwallex paymentSucceeded', [
+            'order_increment_id'  => $order->increment_id,
+            'order_id'  => $order->id
+        ]);
     }
     /**
      * Prepares order's invoice data for creation.

+ 22 - 2
packages/Longyi/Pay/Applepay/src/Http/Controllers/ApplepayController.php

@@ -2,6 +2,7 @@
 
 namespace Longyi\Pay\Applepay\Http\Controllers;
 
+use Illuminate\Support\Facades\Log;
 use Webkul\Checkout\Facades\Cart;
 use Webkul\Sales\Models\Order;
 use Webkul\Sales\Repositories\OrderRepository;
@@ -22,8 +23,13 @@ class ApplepayController extends Controller
 
     public function hook()
     {
-        $header = request()->header();
+        $header = $this->getAllHeaders();
         $rawBody = request()->getContent();
+        Log::channel('payment')->info('applepay hook request received', [
+            'query'     => request()->query(),
+            'header'     => $header,
+            'raw_body'  => json_decode($rawBody, true),
+        ]);
         $event = json_decode($rawBody, true);
         $signatureResult = $this->applepay->webhookSignature($header, $event);
         if (!$signatureResult) {
@@ -43,6 +49,20 @@ class ApplepayController extends Controller
                 @header("HTTP/1.0 200 ok");
                 break;
         }
+        echo 'success';exit;
+    }
+    public function getAllHeaders()
+    {
+        $headers = array();
+        foreach ($_SERVER as $key => $value) {
+            if (substr($key, 0, 5) === 'HTTP_') {
+                $key = substr($key, 5);
+                $key = str_replace('_', ' ', $key);
+                $key = str_replace(' ', '-', $key);
+                $key = strtolower($key);
+                $headers[$key] = $value;
+            }
+        }
+        return $headers;
     }
-
 }

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

@@ -5,4 +5,4 @@ use Longyi\Pay\Applepay\Http\Controllers\ApplepayController;
 
 
 // Webhook 路由(独立于 web 中间件,避免 CSRF 验证)
-Route::post('applepay/webhook/hook', [ApplepayController::class, 'hook'])->name('applepay.webhook.hook');
+Route::any('applepay/webhook/hook', [ApplepayController::class, 'hook'])->name('applepay.webhook.hook');

+ 61 - 4
packages/Longyi/Pay/Applepay/src/Payment/Applepay.php

@@ -3,6 +3,7 @@
 namespace Longyi\Pay\Applepay\Payment;
 
 use GuzzleHttp\Client;
+use Illuminate\Support\Facades\Log;
 use Webkul\Payment\Payment\Payment;
 use Webkul\Sales\Models\Order;
 use Webkul\Sales\Repositories\OrderRepository;
@@ -66,16 +67,17 @@ class Applepay extends Payment
     }
     public function createGatewayOrder()
     {
-        return $this->createOrder($this->getOrder(), ['merchantOrderId' => $this->getOrder()->id]);
+        return $this->createOrder($this->getOrder(), ['merchantOrderId' => $this->getOrder()->increment_id]);
     }
     public function createOrder($order, $override)
     {
         $billingAddressLines = $this->getAddressLines($order->billing_address->address);
         $data = [
-            'intent' => 'AUTHORIZE',
+            'intent' => 'CAPTURE',
             'purchase_units' => [
                 [
                     'reference_id' => $override['merchantOrderId'],
+                    'invoice_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),
@@ -113,6 +115,12 @@ class Applepay extends Payment
                         ],
                     ],
                 ]
+            ],
+            'payment_source' => [
+                'apple_pay' => [
+                    'name' => $order->customer_full_name,
+                    'email_address' => $order->customer_email
+                ]
             ]
         ];
         $client = new Client();
@@ -126,9 +134,54 @@ class Applepay extends Payment
             'timeout' => 30,
             'verify' => false
         ]);
-        $resultObject = json_decode($response->getBody()->getContents());
+        $responseBody = $response->getBody()->getContents();
+        Log::channel('payment')->info('appleypay createPayment response', [
+            'order_id'  => $order->increment_id ?? null,
+            'status'    => $response->getStatusCode(),
+            'body'      => json_decode($responseBody, true),
+            'data'      => $data
+        ]);
+        $resultObject = json_decode($responseBody);
         return $resultObject->id;
     }
+    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('applepay order token is error');
+        }
+        return $this->capturePayment($token);
+    }
+    public function capturePayment($id)
+    {
+        $data = [];
+        $client = new Client();
+        $response = $client->request('POST', str_replace('{id}', $id, $this->captureOrderApi), [
+            '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('appleypay capturePayment response', [
+            'status'    => $response->getStatusCode(),
+            'body'      => json_decode($responseBody, true),
+            'data'      => $data
+        ]);
+        $resultObject = json_decode($responseBody);
+        if ($resultObject->purchase_units[0]->payments->captures[0]->status == 'COMPLETED') {
+            return ['transaction_id' => $resultObject->id];
+        } else {
+            throw new \Exception("applepay capture is error");
+        }
+    }
     /**
      * Return cart items.
      *
@@ -188,7 +241,7 @@ class Applepay extends Payment
     //成功修改状态
     public function paymentSucceeded($orderId, $transactionId)
     {
-        $order = $this->orderRepository->find($orderId);
+        $order = $this->orderRepository->findOneByField('increment_id', $orderId);
 
         if (!$order->id) {
             return false;
@@ -207,6 +260,10 @@ class Applepay extends Payment
         if ($order->canInvoice()) {
             $this->invoiceRepository->create($this->prepareInvoiceData($order));
         }
+        Log::channel('payment')->info('applepay paymentSucceeded', [
+            'order_increment_id'  => $order->increment_id,
+            'order_id'  => $order->id
+        ]);
     }
     /**
      * Prepares order's invoice data for creation.

+ 12 - 3
packages/Longyi/Pay/AwxAfterpay/src/Payment/AwxAfterpay.php

@@ -74,7 +74,7 @@ class AwxAfterpay extends Payment
     }
     public function createGatewayOrder()
     {
-        return $this->createPayment($this->getOrder(), ['merchantOrderId' => $this->getOrder()->id], $this->currentInput);
+        return $this->createPayment($this->getOrder(), ['merchantOrderId' => $this->getOrder()->increment_id], $this->currentInput);
     }
     public function createPayment($order, $override, $input)
     {
@@ -130,7 +130,7 @@ class AwxAfterpay extends Payment
             'verify' => false
         ]);
         $responseBody = $response->getBody()->getContents();
-        Log::channel('payment')->info('AwxAfterpay capture response', [
+        Log::channel('payment')->info('AwxAfterpay createPayment response', [
             'order_id'  => $order->increment_id ?? null,
             'status'    => $response->getStatusCode(),
             'body'      => $responseBody,
@@ -190,7 +190,8 @@ class AwxAfterpay extends Payment
             'paymentIntentId'  => $paymentIntentId ?? null,
             'order'  => $order->increment_id ?? null,
             'status'    => $response->getStatusCode(),
-            'body'      => $responseBody,
+            'body'      => json_decode($responseBody, true),
+            'data'      => $data
         ]);
         $result = json_decode($responseBody);
         return $result;
@@ -321,4 +322,12 @@ class AwxAfterpay extends Payment
         }
         return true;
     }
+    public function getDescription()
+    {
+        $description = $this->getConfigData('description');
+        $info = $this->getOrder() ?: Cart::getCart();
+        $amount = round($info->grand_total / 4, 2);
+        $symbol = core()->getCurrentCurrency()->symbol;
+        return sprintf($description, $symbol.$amount);
+    }
 }

+ 6 - 4
packages/Longyi/Pay/AwxKlarna/src/Payment/AwxKlarna.php

@@ -75,7 +75,7 @@ class AwxKlarna extends Payment
     }
     public function createGatewayOrder()
     {
-        return $this->createPayment($this->getOrder(), ['merchantOrderId' => $this->getOrder()->id], $this->getInput());
+        return $this->createPayment($this->getOrder(), ['merchantOrderId' => $this->getOrder()->increment_id], $this->getInput());
     }
     public function createPayment($order, $override, $input)
     {
@@ -131,7 +131,7 @@ class AwxKlarna extends Payment
             'verify' => false
         ]);
         $responseBody = $response->getBody()->getContents();
-        Log::channel('payment')->info('AwxKlarna capture response', [
+        Log::channel('payment')->info('AwxKlarna createPayment response', [
             'order_id'  => $order->increment_id ?? null,
             'status'    => $response->getStatusCode(),
             'body'      => $responseBody,
@@ -197,7 +197,8 @@ class AwxKlarna extends Payment
             'paymentIntentId'  => $paymentIntentId ?? null,
             'order'  => $order->increment_id ?? null,
             'status'    => $response->getStatusCode(),
-            'body'      => $responseBody,
+            'body'      => json_decode($responseBody, true),
+            'data'      => $data
         ]);
         $result = json_decode($responseBody);
         return $result;
@@ -333,9 +334,10 @@ class AwxKlarna extends Payment
 
     public function getDescription()
     {
+        $description = $this->getConfigData('description');
         $info = $this->getOrder() ?: Cart::getCart();
         $amount = round($info->grand_total / 4, 2);
         $symbol = core()->getCurrentCurrency()->symbol;
-        return "4 interest-free payments of {$symbol}{$amount}";
+        return sprintf($description, $symbol.$amount);
     }
 }

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

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

+ 74 - 0
packages/Longyi/Pay/Klarna/src/Config/system.php

@@ -0,0 +1,74 @@
+<?php
+
+return [
+    [
+        'key'    => 'sales.payment_methods.klarna',
+        'name'   => 'Klarna', // use translation
+        'info'   => 'Information about Klarna', // use translation
+        'sort'   => 10,
+        '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' => 'username',
+                'type' => 'text',
+                'depend' => 'active:1',
+                'validation' => 'required_if:active,1',
+                'channel_based' => false,
+                'locale_based' => true,
+            ], [
+                'name' => 'secret_id',
+                'title' => 'password',
+                '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'          => 'active',
+                'title'         => 'Status', // use translation
+                'type'          => 'boolean',
+                'validation'    => 'required',
+                'channel_based' => false,
+                'locale_based'  => true,
+            ]
+        ]
+    ]
+];

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

@@ -0,0 +1,12 @@
+<?php
+
+namespace Longyi\Pay\Klarna\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;
+}

+ 72 - 0
packages/Longyi/Pay/Klarna/src/Http/Controllers/KlarnaController.php

@@ -0,0 +1,72 @@
+<?php
+
+namespace Longyi\Pay\Klarna\Http\Controllers;
+
+use Illuminate\Support\Facades\Log;
+use Longyi\Pay\Klarna\Payment\Klarna;
+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 Klarna $klarna,
+    ) {}
+
+
+    public function hook()
+    {
+        $rawBody = request()->getContent();
+        $orderId = request()->get('orderId', 0);
+        $secretToken = request()->get('secretToken', '');
+        Log::channel('payment')->info('klarna hook request received', [
+            'query'     => request()->query(),
+            'order_id'  => $orderId,
+            'secretToken'  => $secretToken,
+            'raw_body'  => json_decode($rawBody, true),
+        ]);
+        // 验证签名
+        if (!$this->klarna->verifySignature($orderId, $secretToken)) {
+            @header("HTTP/1.0 400 Bad Request");
+            exit;
+        }
+        if (empty($orderId)) {
+            @header("HTTP/1.0 400 Bad Request");
+            exit;
+        }
+        $order = $this->orderRepository->find($orderId);
+        if (empty($order)) {
+            @header("HTTP/1.0 400 Bad Request");
+            exit;
+        }
+        $event = json_decode($rawBody);
+        if ($event->merchant_reference2 != $orderId) {
+            @header("HTTP/1.0 400 Bad Request");
+            exit;
+        }
+        try {
+            $this->klarna->paymentSucceeded($order, $event->authorization_token);
+            @header("HTTP/1.0 200 ok");
+            echo 'success';exit;
+        } catch (\Exception $e) {
+            //记录日志
+            Log::channel('payment')->error('klarna hook error', [
+                'order' => $order,
+                'event' => $event,
+                'error' => $e->getMessage()
+            ]);
+            @header("HTTP/1.0 400 Bad Request");
+            exit;
+        }
+    }
+
+}

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

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

+ 354 - 0
packages/Longyi/Pay/Klarna/src/Payment/Klarna.php

@@ -0,0 +1,354 @@
+<?php
+
+namespace Longyi\Pay\Klarna\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 Klarna extends Payment
+{
+    /**
+     * Payment method code
+     *
+     * @var string
+     */
+    protected $code  = 'klarna';
+    protected $clientId;
+    protected $apikey;
+    protected $secret;
+    protected $currentOrder;
+    protected $currentInput;
+    protected $createSessionUlr = 'https://api-na.playground.klarna.com/payments/v1/sessions';
+    protected $createOrderUlr = 'https://api-na.playground.klarna.com/payments/v1/authorizations/{authorizationToken}/order';
+    protected $getOrderUlr = 'https://api-na.playground.klarna.com/ordermanagement/v1/orders/{order_id}';
+    protected $authorizationUrl = null;
+    public function __construct(
+        protected InvoiceRepository $invoiceRepository,
+        protected OrderRepository $orderRepository,
+    )
+    {
+        if ($this->getConfigData('mode') == 'live') {
+            $this->createSessionUlr = 'https://api.klarna.com/payments/v1/sessions';
+            $this->createOrderUlr = 'https://api.klarna.com/payments/v1/authorizations/{authorizationToken}/order';
+            $this->getOrderUlr = 'https://api.klarna.com/ordermanagement/v1/orders/{order_id}';
+        }
+        $this->clientId = $this->getConfigData('client_id');
+        $this->apikey = $this->getConfigData('secret_id');
+        $this->authorizationUrl = $this->getBaseUlr() . '/klarna/webhook/hook';
+    }
+
+
+    /**
+     * 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 setInput($input = null): self
+    {
+        $this->currentInput = $input;
+        return $this;
+    }
+
+    public function getInput()
+    {
+        return $this->currentInput;
+    }
+    public function createGatewayOrder()
+    {
+        try {
+            return $this->createIntents($this->getOrder(), ['merchantOrderId' => $this->getOrder()->increment_id], $this->getInput());
+        } catch (\Exception $e) {
+            throw new \Exception($e->getMessage());
+        }
+    }
+
+    public function createIntents($order, $override, $input = null)
+    {
+        $shop = $order->shipping_address;
+        $bill = $order->billing_address;
+        $products = [];
+        foreach ($order->items as $item) {
+            $data = [
+                'name' => $item->name,
+                'unit_price' => $this->toMinorUnits((float) $item->price),
+                'quantity' => (int) $item->qty_ordered,
+                'total_amount' => $this->toMinorUnits((float) $item->price * (float) $item->qty_ordered),
+            ];
+            array_push($products, $data);
+        }
+        $grandTotal = $this->toMinorUnits((float) $order->grand_total);
+        $data = [
+            'order_amount' => $grandTotal,
+            'merchant_reference1' => $override['merchantOrderId'],
+            'merchant_reference2' => $order->id,
+            'merchant_urls' => [
+                'confirmation_url' => $input->paymentSuccessUrl . '?orderId=' . $order->id,
+                'push_url' => $input->paymentSuccessUrl . '?orderId=' . $order->id,
+                'authorization' => $this->authorizationUrl . '?orderId=' . $order->id . '&secretToken=' . $this->generateSignature($order->id)
+            ],
+            'order_lines' => $products,
+            'purchase_country' => $bill->country,
+            'purchase_currency' => $order->order_currency_code,
+            'shipping_address' => [
+                'given_name' => $shop->first_name,
+                'family_name' => $shop->last_name,
+                'email' => $order->customer_email,
+                'phone' => $shop->phone,
+                'street_address' => $shop->address,
+                'city' => $shop->city,
+                'postal_code' => $shop->postcode,
+                'region' => $shop->state,
+                'country' => $shop->country
+            ]
+        ];
+        $client = new Client();
+        $response = $client->request('POST', $this->createSessionUlr, [
+            '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('klarna createIntents response', [
+            'order'  => $order->increment_id ?? null,
+            'status'    => $response->getStatusCode(),
+            'body'      => json_decode($responseBody, true),
+            'data'      => $data,
+        ]);
+        $resultObject = json_decode($responseBody);
+        if (empty($resultObject->client_token)) {
+            throw new \Exception('klarna api is error');
+        }
+        // 生成 verify_token 并存入 order payment additional
+        $verifyToken = bin2hex(random_bytes(32));
+        if ($order->payment) {
+            $additional = $order->payment->additional ?? [];
+            $additional['klarna_verify_token'] = $verifyToken;
+            $additional['klarna_session_id']  = $resultObject->session_id ?? null;
+            $order->payment->additional = $additional;
+            $order->payment->save();
+        }
+        return $resultObject->client_token . '|' . $verifyToken;
+    }
+
+    /**
+     * 获取一个唯一的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;
+    }
+
+    /**
+     * Convert a decimal currency amount to minor units (cents).
+     */
+    protected function toMinorUnits(float $amount): int
+    {
+        return (int) round($amount * 100);
+    }
+
+    public function captureAndVerify($order, $authorizationToken)
+    {
+        if ($order->status != Order::STATUS_PENDING) {
+            return new \Exception('order status is error');
+        }
+        $input = $this->getInput();
+        $verifyToken = $input->verifyToken ?? null;
+        $storedToken = $order->payment->additional['klarna_verify_token'] ?? null;
+        if (!$verifyToken || !$storedToken || !hash_equals($storedToken, $verifyToken)) {
+            throw new \Exception('klarna verify token is invalid');
+        }
+        $additional = $order->payment->additional;
+        unset($additional['klarna_verify_token']);
+        $order->payment->additional = $additional;
+        $order->payment->save();
+        return $this->createOrder($order, $authorizationToken);
+    }
+
+    public function createOrder($order, $authorizationToken )
+    {
+        $products = [];
+        foreach ($order->items as $item) {
+            $data = [
+                'name' => $item->name,
+                'unit_price' => $this->toMinorUnits((float) $item->price),
+                'quantity' => (int) $item->qty_ordered,
+                'total_amount' => $this->toMinorUnits((float) $item->price * (float) $item->qty_ordered),
+            ];
+            array_push($products, $data);
+        }
+        $bill = $order->billing_address;
+        $data = [
+            'billing_address' => [
+                'given_name' => $bill->first_name,
+                'family_name' => $bill->last_name,
+                'email' => $order->customer_email,
+                'phone' => $bill->phone,
+                'street_address' => $bill->address,
+                'city' => $bill->city,
+                'postal_code' => $bill->postcode,
+                'region' => $bill->state,
+                'country' => $bill->country
+            ],
+            'order_amount' => $this->toMinorUnits((float) $order->grand_total),
+            'purchase_country' => $bill->country,
+            'purchase_currency' => $order->order_currency_code,
+            'order_lines' => $products
+        ];
+        $client = new Client();
+        $response = $client->request('POST', str_replace('{authorizationToken}', $authorizationToken, $this->createOrderUlr), [
+            '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('klarna Create an order response', [
+            'data'     => $data,
+            'status'    => $response->getStatusCode(),
+            'body'      => json_decode($responseBody, true),
+        ]);
+        $resultObject = json_decode($responseBody);
+        if ($resultObject->fraud_status == 'ACCEPTED') {
+            return ['transaction_id' => $resultObject->order_id];
+        } else {
+            throw new \Exception('klarna Create an order  is error');
+        }
+    }
+
+    //成功修改状态
+    public function paymentSucceeded($order, $transactionId)
+    {
+        if (!$order->id) {
+            return false;
+        }
+        if ($order->status != Order::STATUS_PENDING) {
+            return false;
+        }
+        try {
+            $result = $this->createOrder($order, $transactionId);
+            $order->status = Order::STATUS_PROCESSING;
+            $order->save();
+            if ($order->payment) {
+                $additional = $order->payment->additional;
+                $additional['transaction_id'] = $result['transaction_id'] ?? $transactionId;
+                $order->payment->additional = $additional;
+                $order->payment->save();
+            }
+            if ($order->canInvoice()) {
+                $this->invoiceRepository->create($this->prepareInvoiceData($order));
+            }
+        } catch (\Exception $e) {
+            Log::channel('payment')->error('klarna paymentSucceeded error', [
+                'order'  => $order->increment_id ?? null,
+                'message'    => $e->getMessage(),
+            ]);
+            //抛出异常
+            throw $e;
+        }
+        Log::channel('payment')->info('klarna paymentSucceeded', [
+            'order_increment_id'  => $order->increment_id,
+            'order_id'  => $order->id
+        ]);
+        return true;
+    }
+
+    /**
+     * 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;
+    }
+    /**
+     * 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;
+    }
+
+    /**
+     * 生成签名
+     * @return string
+     */
+    public function generateSignature($orderId): string
+    {
+        return md5($orderId . 'veryGood');
+    }
+    //验签
+    public function verifySignature($orderId, $signature)
+    {
+        return $this->generateSignature($orderId) == $signature;
+    }
+
+    public function getBaseUlr()
+    {
+        return  config('app.url');
+    }
+    public function getDescription()
+    {
+        $description = $this->getConfigData('description');
+        $info = $this->getOrder() ?: Cart::getCart();
+        $amount = round($info->grand_total / 4, 2);
+        $symbol = core()->getCurrentCurrency()->symbol;
+        return sprintf($description, $symbol.$amount);
+    }
+}

+ 41 - 0
packages/Longyi/Pay/Klarna/src/Providers/KlarnaServiceProvider.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace Longyi\Pay\Klarna\Providers;
+
+use Illuminate\Support\ServiceProvider;
+
+class KlarnaServiceProvider 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'
+        );
+    }
+}

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

@@ -205,6 +205,21 @@ return [
         'name'  => 'admin::app.acl.delete',
         'route' => 'admin.catalog.families.delete',
         'sort'  => 3,
+    ], [
+        'key'   => 'catalog.questions',
+        'name'  => 'admin::app.acl.product-questions',
+        'route' => 'admin.catalog.questions.index',
+        'sort'  => 5,
+    ], [
+        'key'   => 'catalog.questions.edit',
+        'name'  => 'admin::app.acl.edit',
+        'route' => 'admin.catalog.questions.edit',
+        'sort'  => 1,
+    ], [
+        'key'   => 'catalog.questions.delete',
+        'name'  => 'admin::app.acl.delete',
+        'route' => 'admin.catalog.questions.destroy',
+        'sort'  => 2,
     ],
 
     /*
@@ -575,6 +590,11 @@ return [
         'name'  => 'admin::app.acl.products',
         'route' => 'admin.reporting.products.index',
         'sort'  => 3,
+    ], [
+        'key'   => 'reporting.search_logs',
+        'name'  => 'admin::app.acl.search-logs',
+        'route' => 'admin.reporting.search_logs.index',
+        'sort'  => 4,
     ],
 
     /*

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

@@ -92,6 +92,12 @@ return [
         'route'      => 'admin.catalog.families.index',
         'sort'       => 4,
         'icon'       => '',
+    ], [
+        'key'        => 'catalog.questions',
+        'name'       => 'admin::app.components.layouts.sidebar.product-questions',
+        'route'      => 'admin.catalog.questions.index',
+        'sort'       => 5,
+        'icon'       => '',
     ],
 
     /**
@@ -258,6 +264,12 @@ return [
         'route'      => 'admin.reporting.products.index',
         'sort'       => 3,
         'icon'       => '',
+    ], [
+        'key'        => 'reporting.search_logs',
+        'name'       => 'admin::app.components.layouts.sidebar.search-logs',
+        'route'      => 'admin.reporting.search_logs.index',
+        'sort'       => 4,
+        'icon'       => '',
     ],
 
     /**

+ 9 - 0
packages/Webkul/Admin/src/Config/system.php

@@ -2127,6 +2127,15 @@ return [
                 'channel_based' => true,
                 'locale_based'  => false,
                 'default'       => 'CLIENT_SECRET',
+            ], [
+                'name'          => 'webhook_id',
+                'title'         => 'admin::app.configuration.index.sales.payment-methods.webhook-id',
+                'info'          => 'admin::app.configuration.index.sales.payment-methods.webhook-id-info',
+                'type'          => 'text',
+                'depends'       => 'active:1',
+                'validation'    => 'required_if:active,1',
+                'channel_based' => true,
+                'locale_based'  => false,
             ], [
                 'name'          => 'accepted_currencies',
                 'title'         => 'admin::app.configuration.index.sales.payment-methods.accepted-currencies',

+ 162 - 0
packages/Webkul/Admin/src/DataGrids/Catalog/ProductQuestionAnswerDataGrid.php

@@ -0,0 +1,162 @@
+<?php
+
+namespace Webkul\Admin\DataGrids\Catalog;
+
+use Illuminate\Support\Facades\DB;
+use Webkul\DataGrid\DataGrid;
+
+class ProductQuestionAnswerDataGrid extends DataGrid
+{
+    protected $primaryColumn = 'answer_id';
+
+    public function __construct(protected int $questionId)
+    {
+        parent::__construct();
+    }
+
+    public function prepareQueryBuilder()
+    {
+        $queryBuilder = DB::table('product_question_answers')
+            ->leftJoin('customers', 'product_question_answers.customer_id', '=', 'customers.id')
+            ->select(
+                'product_question_answers.id as answer_id',
+                'product_question_answers.question_id',
+                'product_question_answers.answer',
+                'product_question_answers.customer_name',
+                'product_question_answers.status as answer_status',
+                'product_question_answers.is_pinned',
+                'product_question_answers.useful_count',
+                'product_question_answers.created_at',
+                DB::raw("CONCAT(COALESCE(customers.first_name,''), ' ', COALESCE(customers.last_name,'')) as customer_full_name")
+            )
+            ->where('product_question_answers.question_id', $this->questionId);
+
+        $this->addFilter('answer_id', 'product_question_answers.id');
+        $this->addFilter('answer_status', 'product_question_answers.status');
+        $this->addFilter('created_at', 'product_question_answers.created_at');
+
+        return $queryBuilder;
+    }
+
+    public function prepareColumns()
+    {
+        $this->addColumn([
+            'index'    => 'answer_id',
+            'label'    => trans('admin::app.catalog.questions.answers.datagrid.id'),
+            'type'     => 'integer',
+            'sortable' => true,
+        ]);
+
+        $this->addColumn([
+            'index'      => 'customer_name',
+            'label'      => trans('admin::app.catalog.questions.answers.datagrid.customer'),
+            'type'       => 'string',
+            'searchable' => true,
+            'sortable'   => true,
+        ]);
+
+        $this->addColumn([
+            'index'      => 'answer',
+            'label'      => trans('admin::app.catalog.questions.answers.datagrid.answer'),
+            'type'       => 'string',
+            'searchable' => true,
+        ]);
+
+        $this->addColumn([
+            'index'              => 'answer_status',
+            'label'              => trans('admin::app.catalog.questions.answers.datagrid.status'),
+            'type'               => 'string',
+            'filterable'         => true,
+            'filterable_type'    => 'dropdown',
+            'filterable_options' => [
+                ['label' => trans('admin::app.catalog.questions.answers.datagrid.pending'),  'value' => 'pending'],
+                ['label' => trans('admin::app.catalog.questions.answers.datagrid.approved'), 'value' => 'approved'],
+                ['label' => trans('admin::app.catalog.questions.answers.datagrid.rejected'), 'value' => 'rejected'],
+            ],
+            'sortable' => true,
+            'closure'  => function ($row) {
+                return match ($row->answer_status) {
+                    'approved' => '<p class="label-active">'.trans('admin::app.catalog.questions.answers.datagrid.approved').'</p>',
+                    'pending'  => '<p class="label-pending">'.trans('admin::app.catalog.questions.answers.datagrid.pending').'</p>',
+                    'rejected' => '<p class="label-canceled">'.trans('admin::app.catalog.questions.answers.datagrid.rejected').'</p>',
+                    default    => $row->answer_status,
+                };
+            },
+        ]);
+
+        $this->addColumn([
+            'index'    => 'is_pinned',
+            'label'    => trans('admin::app.catalog.questions.answers.datagrid.pinned'),
+            'type'     => 'boolean',
+            'sortable' => true,
+            'closure'  => function ($row) {
+                return $row->is_pinned
+                    ? '<p class="label-active">'.trans('admin::app.catalog.questions.answers.datagrid.yes').'</p>'
+                    : '<p class="text-gray-400">'.trans('admin::app.catalog.questions.answers.datagrid.no').'</p>';
+            },
+        ]);
+
+        $this->addColumn([
+            'index'    => 'useful_count',
+            'label'    => trans('admin::app.catalog.questions.answers.datagrid.useful-count'),
+            'type'     => 'integer',
+            'sortable' => true,
+        ]);
+
+        $this->addColumn([
+            'index'           => 'created_at',
+            'label'           => trans('admin::app.catalog.questions.answers.datagrid.date'),
+            'type'            => 'date',
+            'filterable'      => true,
+            'filterable_type' => 'date_range',
+            'sortable'        => true,
+        ]);
+    }
+
+    public function prepareActions()
+    {
+        if (bouncer()->hasPermission('catalog.questions.edit')) {
+            $this->addAction([
+                'index'  => 'edit',
+                'icon'   => 'icon-edit',
+                'title'  => trans('admin::app.catalog.questions.answers.datagrid.edit'),
+                'method' => 'GET',
+                'url'    => fn ($row) => route('admin.catalog.questions.answers.edit', $row->answer_id),
+            ]);
+        }
+
+        if (bouncer()->hasPermission('catalog.questions.delete')) {
+            $this->addAction([
+                'index'  => 'delete',
+                'icon'   => 'icon-delete',
+                'title'  => trans('admin::app.catalog.questions.answers.datagrid.delete'),
+                'method' => 'DELETE',
+                'url'    => fn ($row) => route('admin.catalog.questions.answers.destroy', $row->answer_id),
+            ]);
+        }
+    }
+
+    public function prepareMassActions()
+    {
+        if (bouncer()->hasPermission('catalog.questions.delete')) {
+            $this->addMassAction([
+                'title'  => trans('admin::app.catalog.questions.answers.datagrid.delete'),
+                'url'    => route('admin.catalog.questions.answers.mass_destroy'),
+                'method' => 'POST',
+            ]);
+        }
+
+        if (bouncer()->hasPermission('catalog.questions.edit')) {
+            $this->addMassAction([
+                'title'   => trans('admin::app.catalog.questions.answers.datagrid.update-status'),
+                'method'  => 'POST',
+                'url'     => route('admin.catalog.questions.answers.mass_update'),
+                'options' => [
+                    ['label' => trans('admin::app.catalog.questions.answers.datagrid.pending'),  'value' => 'pending'],
+                    ['label' => trans('admin::app.catalog.questions.answers.datagrid.approved'), 'value' => 'approved'],
+                    ['label' => trans('admin::app.catalog.questions.answers.datagrid.rejected'), 'value' => 'rejected'],
+                ],
+            ]);
+        }
+    }
+}

+ 167 - 0
packages/Webkul/Admin/src/DataGrids/Catalog/ProductQuestionDataGrid.php

@@ -0,0 +1,167 @@
+<?php
+
+namespace Webkul\Admin\DataGrids\Catalog;
+
+use Illuminate\Support\Facades\DB;
+use Webkul\DataGrid\DataGrid;
+
+class ProductQuestionDataGrid extends DataGrid
+{
+    protected $primaryColumn = 'question_id';
+
+    public function prepareQueryBuilder()
+    {
+        $queryBuilder = DB::table('product_questions')
+            ->leftJoin('product_flat', function ($join) {
+                $join->on('product_questions.product_id', '=', 'product_flat.product_id')
+                    ->where('product_flat.channel', core()->getCurrentChannelCode())
+                    ->where('product_flat.locale', app()->getLocale());
+            })
+            ->leftJoin('customers', 'product_questions.customer_id', '=', 'customers.id')
+            ->select(
+                'product_questions.id as question_id',
+                'product_questions.question',
+                'product_questions.customer_name',
+                'product_questions.status as question_status',
+                'product_questions.answers_count',
+                'product_questions.created_at',
+                'product_flat.name as product_name',
+                DB::raw("CONCAT(COALESCE(customers.first_name,''), ' ', COALESCE(customers.last_name,'')) as customer_full_name")
+            );
+
+        $this->addFilter('question_id', 'product_questions.id');
+        $this->addFilter('question_status', 'product_questions.status');
+        $this->addFilter('product_name', 'product_flat.name');
+        $this->addFilter('created_at', 'product_questions.created_at');
+
+        return $queryBuilder;
+    }
+
+    public function prepareColumns()
+    {
+        $this->addColumn([
+            'index'    => 'question_id',
+            'label'    => trans('admin::app.catalog.questions.index.datagrid.id'),
+            'type'     => 'integer',
+            'sortable' => true,
+        ]);
+
+        $this->addColumn([
+            'index'      => 'customer_name',
+            'label'      => trans('admin::app.catalog.questions.index.datagrid.customer'),
+            'type'       => 'string',
+            'searchable' => true,
+            'sortable'   => true,
+        ]);
+
+        $this->addColumn([
+            'index'      => 'product_name',
+            'label'      => trans('admin::app.catalog.questions.index.datagrid.product'),
+            'type'       => 'string',
+            'searchable' => true,
+            'sortable'   => true,
+        ]);
+
+        $this->addColumn([
+            'index'      => 'question',
+            'label'      => trans('admin::app.catalog.questions.index.datagrid.question'),
+            'type'       => 'string',
+            'searchable' => true,
+        ]);
+
+        $this->addColumn([
+            'index'              => 'question_status',
+            'label'              => trans('admin::app.catalog.questions.index.datagrid.status'),
+            'type'               => 'string',
+            'filterable'         => true,
+            'filterable_type'    => 'dropdown',
+            'filterable_options' => [
+                ['label' => trans('admin::app.catalog.questions.index.datagrid.pending'),  'value' => 'pending'],
+                ['label' => trans('admin::app.catalog.questions.index.datagrid.approved'), 'value' => 'approved'],
+                ['label' => trans('admin::app.catalog.questions.index.datagrid.rejected'), 'value' => 'rejected'],
+            ],
+            'sortable' => true,
+            'closure'  => function ($row) {
+                return match ($row->question_status) {
+                    'approved' => '<p class="label-active">'.trans('admin::app.catalog.questions.index.datagrid.approved').'</p>',
+                    'pending'  => '<p class="label-pending">'.trans('admin::app.catalog.questions.index.datagrid.pending').'</p>',
+                    'rejected' => '<p class="label-canceled">'.trans('admin::app.catalog.questions.index.datagrid.rejected').'</p>',
+                    default    => $row->question_status,
+                };
+            },
+        ]);
+
+        $this->addColumn([
+            'index'    => 'answers_count',
+            'label'    => trans('admin::app.catalog.questions.index.datagrid.answers-count'),
+            'type'     => 'integer',
+            'sortable' => true,
+        ]);
+
+        $this->addColumn([
+            'index'           => 'created_at',
+            'label'           => trans('admin::app.catalog.questions.index.datagrid.date'),
+            'type'            => 'date',
+            'filterable'      => true,
+            'filterable_type' => 'date_range',
+            'sortable'        => true,
+        ]);
+    }
+
+    public function prepareActions()
+    {
+        if (bouncer()->hasPermission('catalog.questions.edit')) {
+            $this->addAction([
+                'index'  => 'edit',
+                'icon'   => 'icon-edit',
+                'title'  => trans('admin::app.catalog.questions.index.datagrid.edit'),
+                'method' => 'GET',
+                'url'    => fn ($row) => route('admin.catalog.questions.edit', $row->question_id),
+            ]);
+        }
+
+        if (bouncer()->hasPermission('catalog.questions.edit')) {
+            $this->addAction([
+                'index'  => 'answers',
+                'icon'   => 'icon-comments',
+                'title'  => trans('admin::app.catalog.questions.index.datagrid.view-answers'),
+                'method' => 'GET',
+                'url'    => fn ($row) => route('admin.catalog.questions.answers', $row->question_id),
+            ]);
+        }
+
+        if (bouncer()->hasPermission('catalog.questions.delete')) {
+            $this->addAction([
+                'index'  => 'delete',
+                'icon'   => 'icon-delete',
+                'title'  => trans('admin::app.catalog.questions.index.datagrid.delete'),
+                'method' => 'DELETE',
+                'url'    => fn ($row) => route('admin.catalog.questions.destroy', $row->question_id),
+            ]);
+        }
+    }
+
+    public function prepareMassActions()
+    {
+        if (bouncer()->hasPermission('catalog.questions.delete')) {
+            $this->addMassAction([
+                'title'  => trans('admin::app.catalog.questions.index.datagrid.delete'),
+                'url'    => route('admin.catalog.questions.mass_destroy'),
+                'method' => 'POST',
+            ]);
+        }
+
+        if (bouncer()->hasPermission('catalog.questions.edit')) {
+            $this->addMassAction([
+                'title'   => trans('admin::app.catalog.questions.index.datagrid.update-status'),
+                'method'  => 'POST',
+                'url'     => route('admin.catalog.questions.mass_update'),
+                'options' => [
+                    ['label' => trans('admin::app.catalog.questions.index.datagrid.pending'),  'value' => 'pending'],
+                    ['label' => trans('admin::app.catalog.questions.index.datagrid.approved'), 'value' => 'approved'],
+                    ['label' => trans('admin::app.catalog.questions.index.datagrid.rejected'), 'value' => 'rejected'],
+                ],
+            ]);
+        }
+    }
+}

+ 84 - 0
packages/Webkul/Admin/src/DataGrids/Reporting/SearchLogDataGrid.php

@@ -0,0 +1,84 @@
+<?php
+
+namespace Webkul\Admin\DataGrids\Reporting;
+
+use Illuminate\Support\Facades\DB;
+use Webkul\DataGrid\DataGrid;
+
+class SearchLogDataGrid extends DataGrid
+{
+    public function prepareQueryBuilder()
+    {
+        $queryBuilder = DB::table('search_log')
+            ->select(
+                'id',
+                'search_text',
+                'query_text',
+                'results',
+                'ip_address',
+                'created_at'
+            )
+            ->orderByDesc('created_at');
+
+        foreach (['id', 'search_text', 'query_text', 'results', 'ip_address', 'created_at'] as $column) {
+            $this->addFilter($column, 'search_log.'.$column);
+        }
+
+        return $queryBuilder;
+    }
+
+    public function prepareColumns()
+    {
+        $this->addColumn([
+            'index'      => 'id',
+            'label'      => trans('admin::app.reporting.search-logs.datagrid.id'),
+            'type'       => 'integer',
+            'filterable' => true,
+            'sortable'   => true,
+        ]);
+
+        $this->addColumn([
+            'index'      => 'search_text',
+            'label'      => trans('admin::app.reporting.search-logs.datagrid.search-text'),
+            'type'       => 'string',
+            'searchable' => true,
+            'filterable' => true,
+            'sortable'   => true,
+        ]);
+
+        $this->addColumn([
+            'index'      => 'query_text',
+            'label'      => trans('admin::app.reporting.search-logs.datagrid.query-text'),
+            'type'       => 'string',
+            'searchable' => true,
+            'filterable' => true,
+            'sortable'   => true,
+        ]);
+
+        $this->addColumn([
+            'index'      => 'results',
+            'label'      => trans('admin::app.reporting.search-logs.datagrid.results'),
+            'type'       => 'integer',
+            'filterable' => true,
+            'sortable'   => true,
+        ]);
+
+        $this->addColumn([
+            'index'      => 'ip_address',
+            'label'      => trans('admin::app.reporting.search-logs.datagrid.ip-address'),
+            'type'       => 'string',
+            'searchable' => true,
+            'filterable' => true,
+            'sortable'   => true,
+        ]);
+
+        $this->addColumn([
+            'index'           => 'created_at',
+            'label'           => trans('admin::app.reporting.search-logs.datagrid.created-at'),
+            'type'            => 'date',
+            'filterable'      => true,
+            'filterable_type' => 'date_range',
+            'sortable'        => true,
+        ]);
+    }
+}

+ 295 - 0
packages/Webkul/Admin/src/Http/Controllers/Catalog/ProductQuestionController.php

@@ -0,0 +1,295 @@
+<?php
+
+namespace Webkul\Admin\Http\Controllers\Catalog;
+
+use Illuminate\Http\JsonResponse;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Event;
+use Webkul\Admin\DataGrids\Catalog\ProductQuestionAnswerDataGrid;
+use Webkul\Admin\DataGrids\Catalog\ProductQuestionDataGrid;
+use Webkul\Admin\Http\Controllers\Controller;
+use Webkul\Admin\Http\Requests\MassDestroyRequest;
+use Webkul\Admin\Http\Requests\MassUpdateRequest;
+
+class ProductQuestionController extends Controller
+{
+    // =========================================================
+    // Questions
+    // =========================================================
+
+    public function index()
+    {
+        if (request()->ajax()) {
+            return datagrid(ProductQuestionDataGrid::class)->process();
+        }
+
+        return view('admin::catalog.questions.index');
+    }
+
+    public function edit(int $id): JsonResponse
+    {
+        $question = DB::table('product_questions')
+            ->leftJoin('product_flat', 'product_questions.product_id', '=', 'product_flat.product_id')
+            ->leftJoin('customers', 'product_questions.customer_id', '=', 'customers.id')
+            ->select(
+                'product_questions.*',
+                'product_flat.name as product_name',
+                DB::raw("CONCAT(COALESCE(customers.first_name,''), ' ', COALESCE(customers.last_name,'')) as full_name")
+            )
+            ->where('product_flat.channel', core()->getCurrentChannelCode())
+            ->where('product_flat.locale', app()->getLocale())
+            ->where('product_questions.id', $id)
+            ->first();
+
+        if (! $question) {
+            return new JsonResponse(['message' => 'Question not found.'], 404);
+        }
+
+        return new JsonResponse(['data' => $question]);
+    }
+
+    public function update(int $id): JsonResponse
+    {
+        $this->validate(request(), [
+            'status' => 'required|in:approved,pending,rejected',
+        ]);
+
+        $updated = DB::table('product_questions')->where('id', $id)->update([
+            'status'     => request()->input('status'),
+            'updated_at' => now(),
+        ]);
+
+        if (! $updated) {
+            return new JsonResponse(['message' => trans('admin::app.catalog.questions.index.edit.not-found')], 404);
+        }
+
+        return new JsonResponse([
+            'message' => trans('admin::app.catalog.questions.index.edit.update-success'),
+        ]);
+    }
+
+    public function destroy(int $id): JsonResponse
+    {
+        try {
+            DB::table('product_questions')->where('id', $id)->delete();
+
+            return new JsonResponse([
+                'message' => trans('admin::app.catalog.questions.index.datagrid.delete-success'),
+            ]);
+        } catch (\Exception $e) {
+            return new JsonResponse(['message' => $e->getMessage()], 500);
+        }
+    }
+
+    public function massDestroy(MassDestroyRequest $massDestroyRequest): JsonResponse
+    {
+        try {
+            DB::table('product_questions')
+                ->whereIn('id', $massDestroyRequest->input('indices'))
+                ->delete();
+
+            return new JsonResponse([
+                'message' => trans('admin::app.catalog.questions.index.datagrid.mass-delete-success'),
+            ]);
+        } catch (\Exception $e) {
+            return new JsonResponse(['message' => $e->getMessage()], 500);
+        }
+    }
+
+    public function massUpdate(MassUpdateRequest $massUpdateRequest): JsonResponse
+    {
+        DB::table('product_questions')
+            ->whereIn('id', $massUpdateRequest->input('indices'))
+            ->update([
+                'status'     => $massUpdateRequest->input('value'),
+                'updated_at' => now(),
+            ]);
+
+        return new JsonResponse([
+            'message' => trans('admin::app.catalog.questions.index.datagrid.mass-update-success'),
+        ]);
+    }
+
+    // =========================================================
+    // Answers (scoped under a question)
+    // =========================================================
+
+    public function answers(int $questionId)
+    {
+        $question = DB::table('product_questions')->where('id', $questionId)->first();
+
+        if (! $question) {
+            abort(404);
+        }
+
+        if (request()->ajax()) {
+            return datagrid(ProductQuestionAnswerDataGrid::class, $questionId)->process();
+        }
+
+        return view('admin::catalog.questions.answers', compact('question'));
+    }
+
+    public function storeAnswer(int $questionId): JsonResponse
+    {
+        $this->validate(request(), [
+            'answer'   => 'required|string|max:5000',
+            'status'   => 'in:approved,pending,rejected',
+            'is_pinned'=> 'nullable|boolean',
+        ]);
+
+        $question = DB::table('product_questions')->where('id', $questionId)->first();
+
+        if (! $question) {
+            return new JsonResponse(['message' => trans('admin::app.catalog.questions.answers.create.not-found')], 404);
+        }
+
+        $adminUser = auth()->guard('admin')->user();
+
+        $isPinned = (bool) request()->input('is_pinned', false);
+
+        if ($isPinned) {
+            DB::table('product_question_answers')
+                ->where('question_id', $questionId)
+                ->update(['is_pinned' => false, 'updated_at' => now()]);
+        }
+
+        $answerId = DB::table('product_question_answers')->insertGetId([
+            'question_id'   => $questionId,
+            'customer_id'   => null,
+            'customer_name' => $adminUser ? ($adminUser->name ?? $adminUser->email) : 'Admin',
+            'customer_email'=> $adminUser ? $adminUser->email : null,
+            'answer'        => request()->input('answer'),
+            'status'        => request()->input('status', 'approved'),
+            'is_pinned'     => $isPinned,
+            'useful_count'  => 0,
+            'created_at'    => now(),
+            'updated_at'    => now(),
+        ]);
+
+        DB::table('product_questions')
+            ->where('id', $questionId)
+            ->increment('answers_count');
+
+        return new JsonResponse([
+            'message' => trans('admin::app.catalog.questions.answers.create.success'),
+            'id'      => $answerId,
+        ]);
+    }
+
+    public function editAnswer(int $id): JsonResponse
+    {
+        $answer = DB::table('product_question_answers')
+            ->leftJoin('customers', 'product_question_answers.customer_id', '=', 'customers.id')
+            ->select(
+                'product_question_answers.*',
+                DB::raw("CONCAT(COALESCE(customers.first_name,''), ' ', COALESCE(customers.last_name,'')) as full_name")
+            )
+            ->where('product_question_answers.id', $id)
+            ->first();
+
+        if (! $answer) {
+            return new JsonResponse(['message' => 'Answer not found.'], 404);
+        }
+
+        return new JsonResponse(['data' => $answer]);
+    }
+
+    public function updateAnswer(int $id): JsonResponse
+    {
+        $this->validate(request(), [
+            'status'    => 'required|in:approved,pending,rejected',
+            'is_pinned' => 'nullable|boolean',
+        ]);
+
+        $answer = DB::table('product_question_answers')->where('id', $id)->first();
+
+        if (! $answer) {
+            return new JsonResponse(['message' => trans('admin::app.catalog.questions.answers.edit.not-found')], 404);
+        }
+
+        $isPinned = (bool) request()->input('is_pinned', false);
+
+        // Unpin other answers for the same question when pinning this one
+        if ($isPinned) {
+            DB::table('product_question_answers')
+                ->where('question_id', $answer->question_id)
+                ->where('id', '!=', $id)
+                ->update(['is_pinned' => false, 'updated_at' => now()]);
+        }
+
+        DB::table('product_question_answers')->where('id', $id)->update([
+            'status'     => request()->input('status'),
+            'is_pinned'  => $isPinned,
+            'updated_at' => now(),
+        ]);
+
+        return new JsonResponse([
+            'message' => trans('admin::app.catalog.questions.answers.edit.update-success'),
+        ]);
+    }
+
+    public function destroyAnswer(int $id): JsonResponse
+    {
+        try {
+            $answer = DB::table('product_question_answers')->where('id', $id)->first();
+
+            if ($answer) {
+                DB::table('product_question_answers')->where('id', $id)->delete();
+
+                // Keep answers_count in sync
+                DB::table('product_questions')
+                    ->where('id', $answer->question_id)
+                    ->where('answers_count', '>', 0)
+                    ->decrement('answers_count');
+            }
+
+            return new JsonResponse([
+                'message' => trans('admin::app.catalog.questions.answers.datagrid.delete-success'),
+            ]);
+        } catch (\Exception $e) {
+            return new JsonResponse(['message' => $e->getMessage()], 500);
+        }
+    }
+
+    public function massDestroyAnswers(MassDestroyRequest $massDestroyRequest): JsonResponse
+    {
+        try {
+            $ids = $massDestroyRequest->input('indices');
+
+            // Gather question IDs to update counts
+            $answers = DB::table('product_question_answers')
+                ->whereIn('id', $ids)
+                ->select('question_id')
+                ->get()
+                ->groupBy('question_id');
+
+            DB::table('product_question_answers')->whereIn('id', $ids)->delete();
+
+            foreach ($answers as $questionId => $group) {
+                DB::table('product_questions')
+                    ->where('id', $questionId)
+                    ->decrement('answers_count', $group->count());
+            }
+
+            return new JsonResponse([
+                'message' => trans('admin::app.catalog.questions.answers.datagrid.mass-delete-success'),
+            ]);
+        } catch (\Exception $e) {
+            return new JsonResponse(['message' => $e->getMessage()], 500);
+        }
+    }
+
+    public function massUpdateAnswers(MassUpdateRequest $massUpdateRequest): JsonResponse
+    {
+        DB::table('product_question_answers')
+            ->whereIn('id', $massUpdateRequest->input('indices'))
+            ->update([
+                'status'     => $massUpdateRequest->input('value'),
+                'updated_at' => now(),
+            ]);
+
+        return new JsonResponse([
+            'message' => trans('admin::app.catalog.questions.answers.datagrid.mass-update-success'),
+        ]);
+    }
+}

+ 54 - 0
packages/Webkul/Admin/src/Http/Controllers/Reporting/SearchLogController.php

@@ -0,0 +1,54 @@
+<?php
+
+namespace Webkul\Admin\Http\Controllers\Reporting;
+
+use Carbon\Carbon;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Support\Facades\DB;
+use Webkul\Admin\DataGrids\Reporting\SearchLogDataGrid;
+use Webkul\Admin\Http\Controllers\Controller;
+
+class SearchLogController extends Controller
+{
+    public function index()
+    {
+        if (request()->ajax()) {
+            return datagrid(SearchLogDataGrid::class)->process();
+        }
+
+        return view('admin::reporting.search-logs.index', [
+            'startDate' => now()->subMonth()->startOfDay(),
+            'endDate'   => now()->endOfDay(),
+        ]);
+    }
+
+    public function stats(): JsonResponse
+    {
+        request()->validate([
+            'start' => ['nullable', 'date'],
+            'end'   => ['nullable', 'date', 'after_or_equal:start'],
+        ]);
+
+        $startDate = Carbon::parse(request()->query('start', now()->subMonth()))->startOfDay();
+        $endDate = Carbon::parse(request()->query('end', now()))->endOfDay();
+
+        $query = DB::table('search_log')
+            ->whereBetween('created_at', [$startDate, $endDate]);
+
+        $topQueries = (clone $query)
+            ->select('query_text', DB::raw('COUNT(*) as uses'))
+            ->groupBy('query_text')
+            ->orderByDesc('uses')
+            ->limit(5)
+            ->get();
+
+        return response()->json([
+            'statistics' => [
+                'total'      => (clone $query)->count(),
+                'zeroResult' => (clone $query)->where('results', 0)->count(),
+                'corrected'  => (clone $query)->whereColumn('query_text', '!=', 'search_text')->count(),
+                'topQueries' => $topQueries,
+            ],
+        ]);
+    }
+}

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

@@ -741,6 +741,113 @@ return [
     ],
 
     'catalog' => [
+        'questions' => [
+            'index' => [
+                'title'   => 'Product Q&A',
+
+                'datagrid' => [
+                    'id'             => 'ID',
+                    'customer'       => 'Customer',
+                    'product'        => 'Product',
+                    'question'       => 'Question',
+                    'status'         => 'Status',
+                    'answers-count'  => 'Answers',
+                    'answers-label'  => ':count answers',
+                    'date'           => 'Date',
+                    'pending'        => 'Pending',
+                    'approved'       => 'Approved',
+                    'rejected'       => 'Rejected',
+                    'edit'           => 'Edit',
+                    'delete'         => 'Delete',
+                    'view-answers'   => 'View Answers',
+                    'update-status'  => 'Update Status',
+                    'delete-success'      => 'Question deleted successfully.',
+                    'mass-delete-success' => 'Selected questions deleted successfully.',
+                    'mass-update-success' => 'Selected questions updated successfully.',
+                ],
+
+                'edit' => [
+                    'title'         => 'Edit Question',
+                    'save-btn'      => 'Save',
+                    'customer'      => 'Customer',
+                    'product'       => 'Product',
+                    'id'            => 'ID',
+                    'date'          => 'Date',
+                    'question-text' => 'Question',
+                    'status'        => 'Status',
+                    'pending'       => 'Pending',
+                    'approved'      => 'Approved',
+                    'rejected'      => 'Rejected',
+                    'update-success' => 'Question updated successfully.',
+                    'not-found'      => 'Question not found.',
+                ],
+            ],
+
+            'answers' => [
+                'title'          => 'Answers',
+                'question-label' => 'Question',
+                'by'             => 'By',
+                'status'         => 'Status',
+                'answer-count'   => 'Answers',
+
+                'create' => [
+                    'btn'                => 'Add Answer',
+                    'title'              => 'Create Answer',
+                    'answer-text'        => 'Answer',
+                    'answer-placeholder' => 'Write your answer here…',
+                    'status'             => 'Status',
+                    'pin-answer'         => 'Pin as Best Answer',
+                    'pending'            => 'Pending',
+                    'approved'           => 'Approved',
+                    'rejected'           => 'Rejected',
+                    'yes'                => 'Yes',
+                    'no'                 => 'No',
+                    'cancel'             => 'Cancel',
+                    'save-btn'           => 'Submit Answer',
+                    'success'            => 'Answer created successfully.',
+                    'not-found'          => 'Question not found.',
+                ],
+
+                'datagrid' => [
+                    'id'           => 'ID',
+                    'customer'     => 'Customer',
+                    'answer'       => 'Answer',
+                    'status'       => 'Status',
+                    'pinned'       => 'Pinned',
+                    'useful-count' => 'Useful Votes',
+                    'date'         => 'Date',
+                    'pending'      => 'Pending',
+                    'approved'     => 'Approved',
+                    'rejected'     => 'Rejected',
+                    'yes'          => 'Yes',
+                    'no'           => 'No',
+                    'edit'         => 'Edit',
+                    'delete'       => 'Delete',
+                    'update-status'       => 'Update Status',
+                    'delete-success'      => 'Answer deleted successfully.',
+                    'mass-delete-success' => 'Selected answers deleted successfully.',
+                    'mass-update-success' => 'Selected answers updated successfully.',
+                ],
+
+                'edit' => [
+                    'title'       => 'Edit Answer',
+                    'save-btn'    => 'Save',
+                    'customer'    => 'Customer',
+                    'useful-count'=> 'Useful Votes',
+                    'answer-text' => 'Answer',
+                    'status'      => 'Status',
+                    'pin-answer'  => 'Pin as Best Answer',
+                    'pending'     => 'Pending',
+                    'approved'    => 'Approved',
+                    'rejected'    => 'Rejected',
+                    'yes'         => 'Yes',
+                    'no'          => 'No',
+                    'update-success' => 'Answer updated successfully.',
+                    'not-found'      => 'Answer not found.',
+                ],
+            ],
+        ],
+
         'products' => [
             'index' => [
                 'already-taken' => 'The :name has already been taken.',
@@ -3602,6 +3709,24 @@ return [
     ],
 
     'reporting' => [
+        'search-logs' => [
+            'title'              => 'Search Logs',
+            'total-searches'     => 'Total Searches',
+            'zero-results'       => 'Zero Result Searches',
+            'corrected-searches' => 'Corrected or Relaxed Searches',
+            'top-queries'        => 'Top Effective Queries',
+            'no-records'         => 'No search logs found for this period.',
+
+            'datagrid' => [
+                'id'          => 'ID',
+                'search-text' => 'User Search Text',
+                'query-text'  => 'Effective Query',
+                'results'     => 'Results',
+                'ip-address'  => 'IP Address',
+                'created-at'  => 'Created At',
+            ],
+        ],
+
         'sales' => [
             'index' => [
                 'abandoned-carts'               => 'Abandoned Carts',
@@ -4499,6 +4624,8 @@ return [
                     'sort-order'                     => 'Sort Order',
                     'status'                         => 'Status',
                     'title'                          => 'Title',
+                    'webhook-id'                     => 'Webhook ID',
+                    'webhook-id-info'                => 'Add the ID generated for the PayPal Smart Button webhook.',
                 ],
 
                 'order-settings' => [
@@ -4706,6 +4833,7 @@ return [
                 'attribute-families'       => 'Attribute Families',
                 'attributes'               => 'Attributes',
                 'booking-product'          => 'Bookings',
+                'product-questions'        => 'Product Q&A',
                 'campaigns'                => 'Campaigns',
                 'catalog'                  => 'Catalog',
                 'categories'               => 'Categories',
@@ -4741,6 +4869,7 @@ return [
                 'sales'                    => 'Sales',
                 'search-seo'               => 'Search & SEO',
                 'search-synonyms'          => 'Search Synonyms',
+                'search-logs'              => 'Search Logs',
                 'search-terms'             => 'Search Terms',
                 'settings'                 => 'Settings',
                 'shipments'                => 'Shipments',
@@ -4965,6 +5094,7 @@ return [
         'attribute-families'       => 'Attribute Families',
         'attributes'               => 'Attributes',
         'campaigns'                => 'Campaigns',
+        'product-questions'        => 'Product Q&A',
         'cancel'                   => 'Cancel',
         'cart-rules'               => 'Cart Rules',
         'catalog'                  => 'Catalog',
@@ -5004,6 +5134,7 @@ return [
         'roles'                    => 'Roles',
         'sales'                    => 'Sales',
         'search-seo'               => 'Search & SEO',
+        'search-logs'              => 'Search Logs',
         'search-synonyms'          => 'Search Synonyms',
         'search-terms'             => 'Search Terms',
         'settings'                 => 'Settings',

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

@@ -3602,6 +3602,24 @@ return [
     ],
 
     'reporting' => [
+        'search-logs' => [
+            'title'              => '搜索日志',
+            'total-searches'     => '搜索总次数',
+            'zero-results'       => '零结果搜索',
+            'corrected-searches' => '纠错或降级搜索',
+            'top-queries'        => '热门实际查询词',
+            'no-records'         => '此时间段内没有搜索日志。',
+
+            'datagrid' => [
+                'id'          => 'ID',
+                'search-text' => '用户搜索词',
+                'query-text'  => '实际查询词',
+                'results'     => '结果数量',
+                'ip-address'  => 'IP 地址',
+                'created-at'  => '创建时间',
+            ],
+        ],
+
         'sales' => [
             'index' => [
                 'abandoned-carts'               => '被遗弃的购物车',
@@ -4499,6 +4517,8 @@ return [
                     'sort-order'                     => '排序',
                     'status'                         => '状态',
                     'title'                          => '标题',
+                    'webhook-id'                     => 'Webhook ID',
+                    'webhook-id-info'                => '填写 PayPal Smart Button Webhook 创建后生成的 ID。',
                 ],
 
                 'order-settings' => [
@@ -4741,6 +4761,7 @@ return [
                 'sales'                    => '销售',
                 'search-seo'               => '搜索与SEO',
                 'search-synonyms'          => '搜索同义词',
+                'search-logs'              => '搜索日志',
                 'search-terms'             => '搜索词',
                 'settings'                 => '设置',
                 'shipments'                => '发货',
@@ -5004,6 +5025,7 @@ return [
         'roles'                    => '角色',
         'sales'                    => '销售',
         'search-seo'               => '搜索和SEO',
+        'search-logs'              => '搜索日志',
         'search-synonyms'          => '搜索同义词',
         'search-terms'             => '搜索词',
         'settings'                 => '设置',

+ 542 - 0
packages/Webkul/Admin/src/Resources/views/catalog/questions/answers.blade.php

@@ -0,0 +1,542 @@
+<x-admin::layouts>
+    <x-slot:title>
+        @lang('admin::app.catalog.questions.answers.title')
+    </x-slot>
+
+    <div class="flex items-center justify-between gap-4 max-sm:flex-wrap">
+        <div class="flex items-center gap-2">
+            <a
+                href="{{ route('admin.catalog.questions.index') }}"
+                class="text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-white"
+            >
+                @lang('admin::app.catalog.questions.index.title')
+            </a>
+
+            <span class="text-gray-400">/</span>
+
+            <p class="py-3 text-xl font-bold text-gray-800 dark:text-white">
+                @lang('admin::app.catalog.questions.answers.title')
+            </p>
+        </div>
+
+        @if (bouncer()->hasPermission('catalog.questions.edit'))
+            <button
+                class="primary-button"
+                onclick="document.dispatchEvent(new CustomEvent('open-create-answer'))"
+            >
+                @lang('admin::app.catalog.questions.answers.create.btn')
+            </button>
+        @endif
+    </div>
+
+    <!-- Question context card -->
+    <div class="box-shadow rounded-lg bg-white p-4 dark:bg-gray-900 mb-4">
+        <p class="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-1">
+            @lang('admin::app.catalog.questions.answers.question-label')
+        </p>
+        <p class="text-base text-gray-800 dark:text-white font-medium">
+            {{ $question->question }}
+        </p>
+
+        <div class="flex items-center gap-6 mt-2 text-sm text-gray-500 dark:text-gray-400">
+            <span>
+                @lang('admin::app.catalog.questions.answers.by'): <strong>{{ $question->customer_name }}</strong>
+            </span>
+
+            <span>
+                @lang('admin::app.catalog.questions.answers.status'):
+                <span class="{{ $question->status === 'approved' ? 'text-green-600' : ($question->status === 'rejected' ? 'text-red-500' : 'text-yellow-500') }} font-semibold">
+                    {{ ucfirst($question->status) }}
+                </span>
+            </span>
+
+            <span>
+                @lang('admin::app.catalog.questions.answers.answer-count'): {{ $question->answers_count }}
+            </span>
+        </div>
+    </div>
+
+    <v-answer-edit-drawer></v-answer-edit-drawer>
+    <v-create-answer-modal></v-create-answer-modal>
+
+    @pushOnce('scripts')
+        <script
+            type="text/x-template"
+            id="v-answer-edit-drawer-template"
+        >
+            <x-admin::datagrid
+                :src="route('admin.catalog.questions.answers', {{ $question->id }})"
+                :isMultiRow="true"
+                ref="answer_data"
+            >
+                @php
+                    $hasEditPermission   = bouncer()->hasPermission('catalog.questions.edit');
+                    $hasDeletePermission = bouncer()->hasPermission('catalog.questions.delete');
+                    $hasPermission       = $hasEditPermission || $hasDeletePermission;
+                @endphp
+
+                <template #header="{
+                    isLoading,
+                    available,
+                    applied,
+                    selectAll,
+                    sort,
+                    performAction
+                }">
+                    <template v-if="isLoading">
+                        <x-admin::shimmer.datagrid.table.head :isMultiRow="true" />
+                    </template>
+
+                    <template v-else>
+                        <div class="row grid grid-cols-1 md:grid-cols-[2fr_1fr_minmax(150px,_3fr)_0.5fr] grid-rows-1 gap-1 items-center border-b px-4 py-2.5 dark:border-gray-800 min-w-full">
+                            <div
+                                class="flex items-center gap-2.5"
+                                v-for="(columnGroup, index) in [['customer_name', 'answer_status', 'is_pinned'], ['useful_count', 'created_at', 'answer_id'], ['answer']]"
+                            >
+                                @if ($hasPermission)
+                                    <label
+                                        class="flex w-max cursor-pointer select-none items-center gap-1"
+                                        for="mass_action_select_all_records"
+                                        v-if="! index"
+                                    >
+                                        <input
+                                            type="checkbox"
+                                            id="mass_action_select_all_records"
+                                            class="peer hidden"
+                                            name="mass_action_select_all_records"
+                                            :checked="['all', 'partial'].includes(applied.massActions.meta.mode)"
+                                            @change="selectAll"
+                                        >
+
+                                        <span
+                                            class="icon-uncheckbox cursor-pointer rounded-md text-2xl"
+                                            :class="[
+                                                applied.massActions.meta.mode === 'all' ? 'peer-checked:icon-checked peer-checked:text-blue-600' : (
+                                                    applied.massActions.meta.mode === 'partial' ? 'peer-checked:icon-checkbox-partial peer-checked:text-blue-600' : ''
+                                                ),
+                                            ]"
+                                        >
+                                        </span>
+                                    </label>
+                                @endif
+
+                                <p class="text-gray-600 dark:text-gray-300">
+                                    <span class="[&>*]:after:content-['_/_']">
+                                        <template v-for="column in columnGroup">
+                                            <span
+                                                class="after:content-['/'] last:after:content-['']"
+                                                :class="{
+                                                    'font-medium text-gray-800 dark:text-white': applied.sort.column == column,
+                                                    'cursor-pointer hover:text-gray-800 dark:hover:text-white': available.columns.find(c => c.index === column)?.sortable,
+                                                }"
+                                                @click="available.columns.find(c => c.index === column)?.sortable ? sort(available.columns.find(c => c.index === column)) : {}"
+                                            >
+                                                @{{ available.columns.find(c => c.index === column)?.label }}
+                                            </span>
+                                        </template>
+                                    </span>
+
+                                    <i
+                                        class="align-text-bottom text-base text-gray-800 dark:text-white ltr:ml-1.5 rtl:mr-1.5"
+                                        :class="[applied.sort.order === 'asc' ? 'icon-down-stat' : 'icon-up-stat']"
+                                        v-if="columnGroup.includes(applied.sort.column)"
+                                    ></i>
+                                </p>
+                            </div>
+                        </div>
+                    </template>
+                </template>
+
+                <template #body="{
+                    isLoading,
+                    available,
+                    applied,
+                    selectAll,
+                    sort,
+                    performAction
+                }">
+                    <template v-if="isLoading">
+                        <x-admin::shimmer.datagrid.table.body :isMultiRow="true" />
+                    </template>
+
+                    <template v-else>
+                        <div
+                            class="row grid grid-cols-1 gap-2 md:grid-cols-[2fr_1fr_minmax(150px,_3fr)_0.5fr] md:gap-0 border-b px-4 py-2.5 transition-all hover:bg-gray-50 dark:border-gray-800 dark:hover:bg-gray-950 min-w-full"
+                            v-for="record in available.records"
+                        >
+                            <!-- Customer, Status, Pinned -->
+                            <div class="flex gap-2.5">
+                                @if ($hasPermission)
+                                    <input
+                                        type="checkbox"
+                                        :id="`mass_action_select_record_${record.answer_id}`"
+                                        class="peer hidden"
+                                        :name="`mass_action_select_record_${record.answer_id}`"
+                                        :value="record.answer_id"
+                                        v-model="applied.massActions.indices"
+                                        @change="setCurrentSelectionMode"
+                                    >
+
+                                    <label
+                                        class="icon-uncheckbox peer-checked:icon-checked cursor-pointer rounded-md text-2xl peer-checked:text-blue-600"
+                                        :for="`mass_action_select_record_${record.answer_id}`"
+                                    ></label>
+                                @endif
+
+                                <div class="flex flex-col gap-1.5">
+                                    <p class="text-base font-semibold text-gray-800 dark:text-white">
+                                        @{{ record.customer_name }}
+                                    </p>
+
+                                    <p v-html="record.answer_status"></p>
+
+                                    <p v-html="record.is_pinned"></p>
+                                </div>
+                            </div>
+
+                            <!-- Useful count, Date, ID -->
+                            <div class="flex flex-col gap-1.5 ps-8 md:ps-0">
+                                <p class="text-gray-800 dark:text-white">
+                                    👍 @{{ record.useful_count }}
+                                </p>
+
+                                <p class="text-gray-600 dark:text-gray-300">
+                                    @{{ record.created_at }}
+                                </p>
+
+                                <p class="text-gray-600 dark:text-gray-300">
+                                    @{{ "#" + record.answer_id }}
+                                </p>
+                            </div>
+
+                            <!-- Answer text -->
+                            <div class="flex flex-col gap-1.5 ps-8 md:ps-0">
+                                <p class="text-gray-800 dark:text-white">
+                                    @{{ record.answer }}
+                                </p>
+                            </div>
+
+                            <!-- Actions -->
+                            <div class="flex place-content-end items-center gap-1.5 self-center">
+                                <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 ltr:ml-1 rtl:mr-1"
+                                    ></span>
+                                </a>
+
+                                <a
+                                    v-if="record.actions.find(action => action.index === 'edit')"
+                                    @click="edit(record.actions.find(action => action.index === 'edit')?.url)"
+                                >
+                                    <span class="icon-sort-right rtl:icon-sort-left cursor-pointer rounded-md p-1.5 text-2xl transition-all hover:bg-gray-200 dark:hover:bg-gray-800 ltr:ml-1 rtl:mr-1"></span>
+                                </a>
+                            </div>
+                        </div>
+                    </template>
+                </template>
+            </x-admin::datagrid>
+
+            <!-- Edit Drawer -->
+            <div class="flex flex-1 flex-col gap-2 max-xl:flex-auto">
+                <x-admin::form
+                    v-slot="{ meta, errors, handleSubmit }"
+                    as="div"
+                >
+                    <form
+                        @submit="handleSubmit($event, update)"
+                        ref="answerForm"
+                    >
+                        <x-admin::drawer ref="answerDrawer">
+                            <x-slot:header>
+                                <div class="flex items-center justify-between">
+                                    <p class="text-xl font-medium dark:text-white">
+                                        @lang('admin::app.catalog.questions.answers.edit.title')
+                                    </p>
+
+                                    <button class="primary-button ltr:mr-11 rtl:ml-11">
+                                        @lang('admin::app.catalog.questions.answers.edit.save-btn')
+                                    </button>
+                                </div>
+                            </x-slot>
+
+                            <x-slot:content>
+                                <div class="flex flex-col gap-4 px-1.5 py-2.5">
+                                    <div class="grid grid-cols-2 gap-4">
+                                        <div>
+                                            <p class="text-xs font-semibold text-gray-600 dark:text-gray-300">
+                                                @lang('admin::app.catalog.questions.answers.edit.customer')
+                                            </p>
+                                            <p class="font-semibold text-gray-800 dark:text-white">
+                                                @{{ answer.customer_name || 'N/A' }}
+                                            </p>
+                                        </div>
+
+                                        <div>
+                                            <p class="text-xs font-semibold text-gray-600 dark:text-gray-300">
+                                                @lang('admin::app.catalog.questions.answers.edit.useful-count')
+                                            </p>
+                                            <p class="font-semibold text-gray-800 dark:text-white">
+                                                👍 @{{ answer.useful_count }}
+                                            </p>
+                                        </div>
+                                    </div>
+
+                                    <div class="w-full">
+                                        <p class="block text-xs font-semibold leading-6 text-gray-600 dark:text-gray-300">
+                                            @lang('admin::app.catalog.questions.answers.edit.answer-text')
+                                        </p>
+                                        <p class="text-gray-800 dark:text-white">
+                                            @{{ answer.answer }}
+                                        </p>
+                                    </div>
+
+                                    <div class="w-full">
+                                        <x-admin::form.control-group.control
+                                            type="hidden"
+                                            name="id"
+                                            rules="required"
+                                            ::value="answer.id"
+                                        />
+
+                                        <x-admin::form.control-group>
+                                            <x-admin::form.control-group.label class="required">
+                                                @lang('admin::app.catalog.questions.answers.edit.status')
+                                            </x-admin::form.control-group.label>
+
+                                            <x-admin::form.control-group.control
+                                                type="select"
+                                                name="status"
+                                                rules="required"
+                                                ::value="answer.status"
+                                            >
+                                                <option value="pending">
+                                                    @lang('admin::app.catalog.questions.answers.edit.pending')
+                                                </option>
+                                                <option value="approved">
+                                                    @lang('admin::app.catalog.questions.answers.edit.approved')
+                                                </option>
+                                                <option value="rejected">
+                                                    @lang('admin::app.catalog.questions.answers.edit.rejected')
+                                                </option>
+                                            </x-admin::form.control-group.control>
+
+                                            <x-admin::form.control-group.error control-name="status" />
+                                        </x-admin::form.control-group>
+                                    </div>
+
+                                    <div class="w-full">
+                                        <x-admin::form.control-group>
+                                            <x-admin::form.control-group.label>
+                                                @lang('admin::app.catalog.questions.answers.edit.pin-answer')
+                                            </x-admin::form.control-group.label>
+
+                                            <x-admin::form.control-group.control
+                                                type="select"
+                                                name="is_pinned"
+                                                ::value="answer.is_pinned ? '1' : '0'"
+                                            >
+                                                <option value="0">
+                                                    @lang('admin::app.catalog.questions.answers.edit.no')
+                                                </option>
+                                                <option value="1">
+                                                    @lang('admin::app.catalog.questions.answers.edit.yes')
+                                                </option>
+                                            </x-admin::form.control-group.control>
+                                        </x-admin::form.control-group>
+                                    </div>
+                                </div>
+                            </x-slot>
+                        </x-admin::drawer>
+                    </form>
+                </x-admin::form>
+            </div>
+        </script>
+
+        <script type="module">
+            app.component('v-answer-edit-drawer', {                template: '#v-answer-edit-drawer-template',
+
+                data() {
+                    return {
+                        answer: {},
+                    }
+                },
+
+                methods: {
+                    edit(url) {
+                        this.$axios.get(url)
+                            .then((response) => {
+                                this.answer = response.data.data;
+                                this.$refs.answerDrawer.open();
+                            })
+                            .catch(error => {
+                                this.$emitter.emit('add-flash', { type: 'error', message: error.response?.data?.message || 'Error' });
+                            });
+                    },
+
+                    update(params) {
+                        let formData = new FormData(this.$refs.answerForm);
+                        formData.append('_method', 'put');
+
+                        this.$axios.post(`{{ route('admin.catalog.questions.answers.update', '') }}/${params.id}`, formData)
+                            .then((response) => {
+                                this.$refs.answerDrawer.close();
+                                this.$refs.answer_data.get();
+                                this.$emitter.emit('add-flash', { type: 'success', message: response.data.message });
+                            })
+                            .catch(error => {
+                                if (error.response?.status === 422) {
+                                    setErrors(error.response.data.errors);
+                                }
+                            });
+                    },
+                }
+            })
+        </script>
+
+        {{-- Create Answer Modal --}}
+        <script
+            type="text/x-template"
+            id="v-create-answer-modal-template"
+        >
+            <x-admin::form
+                v-slot="{ meta, errors, handleSubmit }"
+                as="div"
+            >
+                <form
+                    @submit="handleSubmit($event, store)"
+                    ref="createAnswerForm"
+                >
+                    <x-admin::modal ref="createModal">
+                        <x-slot:header>
+                            <p class="text-xl font-medium dark:text-white">
+                                @lang('admin::app.catalog.questions.answers.create.title')
+                            </p>
+                        </x-slot>
+
+                        <x-slot:content>
+                            <div class="flex flex-col gap-4 px-1 py-2">
+                                <div class="box-shadow rounded bg-gray-50 dark:bg-gray-800 p-3 text-sm text-gray-700 dark:text-gray-300">
+                                    {{ $question->question }}
+                                </div>
+
+                                <x-admin::form.control-group>
+                                    <x-admin::form.control-group.label class="required">
+                                        @lang('admin::app.catalog.questions.answers.create.answer-text')
+                                    </x-admin::form.control-group.label>
+
+                                    <x-admin::form.control-group.control
+                                        type="textarea"
+                                        name="answer"
+                                        rules="required|max:5000"
+                                        rows="4"
+                                        :placeholder="trans('admin::app.catalog.questions.answers.create.answer-placeholder')"
+                                    />
+
+                                    <x-admin::form.control-group.error control-name="answer" />
+                                </x-admin::form.control-group>
+
+                                <x-admin::form.control-group>
+                                    <x-admin::form.control-group.label class="required">
+                                        @lang('admin::app.catalog.questions.answers.create.status')
+                                    </x-admin::form.control-group.label>
+
+                                    <x-admin::form.control-group.control
+                                        type="select"
+                                        name="status"
+                                        value="approved"
+                                        rules="required"
+                                    >
+                                        <option value="approved">
+                                            @lang('admin::app.catalog.questions.answers.create.approved')
+                                        </option>
+                                        <option value="pending">
+                                            @lang('admin::app.catalog.questions.answers.create.pending')
+                                        </option>
+                                        <option value="rejected">
+                                            @lang('admin::app.catalog.questions.answers.create.rejected')
+                                        </option>
+                                    </x-admin::form.control-group.control>
+
+                                    <x-admin::form.control-group.error control-name="status" />
+                                </x-admin::form.control-group>
+
+                                <x-admin::form.control-group>
+                                    <x-admin::form.control-group.label>
+                                        @lang('admin::app.catalog.questions.answers.create.pin-answer')
+                                    </x-admin::form.control-group.label>
+
+                                    <x-admin::form.control-group.control
+                                        type="select"
+                                        name="is_pinned"
+                                        value="0"
+                                    >
+                                        <option value="0">
+                                            @lang('admin::app.catalog.questions.answers.create.no')
+                                        </option>
+                                        <option value="1">
+                                            @lang('admin::app.catalog.questions.answers.create.yes')
+                                        </option>
+                                    </x-admin::form.control-group.control>
+                                </x-admin::form.control-group>
+                            </div>
+                        </x-slot>
+
+                        <x-slot:footer>
+                            <div class="flex items-center gap-x-2.5">
+                                <button
+                                    type="button"
+                                    class="transparent-button"
+                                    @click="$refs.createModal.close()"
+                                >
+                                    @lang('admin::app.catalog.questions.answers.create.cancel')
+                                </button>
+
+                                <button type="submit" class="primary-button">
+                                    @lang('admin::app.catalog.questions.answers.create.save-btn')
+                                </button>
+                            </div>
+                        </x-slot>
+                    </x-admin::modal>
+                </form>
+            </x-admin::form>
+        </script>
+
+        <script type="module">
+            app.component('v-create-answer-modal', {
+                template: '#v-create-answer-modal-template',
+
+                mounted() {
+                    document.addEventListener('open-create-answer', () => {
+                        this.$refs.createModal.open();
+                    });
+                },
+
+                methods: {
+                    store(params) {
+                        let formData = new FormData(this.$refs.createAnswerForm);
+
+                        this.$axios.post('{{ route('admin.catalog.questions.answers.store', $question->id) }}', formData)
+                            .then((response) => {
+                                this.$refs.createModal.close();
+                                this.$refs.createAnswerForm.reset();
+
+                                // Refresh the answers DataGrid
+                                document.querySelector('[ref="answer_data"]')
+                                    ?.__vueParentComponent?.refs?.answer_data?.get?.();
+
+                                this.$emitter.emit('add-flash', { type: 'success', message: response.data.message });
+                            })
+                            .catch(error => {
+                                if (error.response?.status === 422) {
+                                    setErrors(error.response.data.errors);
+                                } else {
+                                    this.$emitter.emit('add-flash', { type: 'error', message: error.response?.data?.message || 'Error' });
+                                }
+                            });
+                    },
+                }
+            })
+        </script>
+    @endPushOnce
+</x-admin::layouts>

+ 361 - 0
packages/Webkul/Admin/src/Resources/views/catalog/questions/index.blade.php

@@ -0,0 +1,361 @@
+<x-admin::layouts>
+    <x-slot:title>
+        @lang('admin::app.catalog.questions.index.title')
+    </x-slot>
+
+    <div class="flex items-center justify-between gap-4 max-sm:flex-wrap">
+        <p class="py-3 text-xl font-bold text-gray-800 dark:text-white">
+            @lang('admin::app.catalog.questions.index.title')
+        </p>
+    </div>
+
+    <v-question-edit-drawer></v-question-edit-drawer>
+
+    @pushOnce('scripts')
+        <script
+            type="text/x-template"
+            id="v-question-edit-drawer-template"
+        >
+            <x-admin::datagrid
+                :src="route('admin.catalog.questions.index')"
+                :isMultiRow="true"
+                ref="question_data"
+            >
+                @php
+                    $hasEditPermission   = bouncer()->hasPermission('catalog.questions.edit');
+                    $hasDeletePermission = bouncer()->hasPermission('catalog.questions.delete');
+                    $hasPermission       = $hasEditPermission || $hasDeletePermission;
+                @endphp
+
+                <template #header="{
+                    isLoading,
+                    available,
+                    applied,
+                    selectAll,
+                    sort,
+                    performAction
+                }">
+                    <template v-if="isLoading">
+                        <x-admin::shimmer.datagrid.table.head :isMultiRow="true" />
+                    </template>
+
+                    <template v-else>
+                        <div class="row grid grid-cols-1 md:grid-cols-[2fr_1fr_minmax(150px,_3fr)_0.5fr] grid-rows-1 gap-1 items-center border-b px-4 py-2.5 dark:border-gray-800 min-w-full">
+                            <div
+                                class="flex items-center gap-2.5"
+                                v-for="(columnGroup, index) in [['customer_name', 'product_name', 'question_status'], ['answers_count', 'created_at', 'question_id'], ['question']]"
+                            >
+                                @if ($hasPermission)
+                                    <label
+                                        class="flex w-max cursor-pointer select-none items-center gap-1"
+                                        for="mass_action_select_all_records"
+                                        v-if="! index"
+                                    >
+                                        <input
+                                            type="checkbox"
+                                            id="mass_action_select_all_records"
+                                            class="peer hidden"
+                                            name="mass_action_select_all_records"
+                                            :checked="['all', 'partial'].includes(applied.massActions.meta.mode)"
+                                            @change="selectAll"
+                                        >
+
+                                        <span
+                                            class="icon-uncheckbox cursor-pointer rounded-md text-2xl"
+                                            :class="[
+                                                applied.massActions.meta.mode === 'all' ? 'peer-checked:icon-checked peer-checked:text-blue-600' : (
+                                                    applied.massActions.meta.mode === 'partial' ? 'peer-checked:icon-checkbox-partial peer-checked:text-blue-600' : ''
+                                                ),
+                                            ]"
+                                        >
+                                        </span>
+                                    </label>
+                                @endif
+
+                                <p class="text-gray-600 dark:text-gray-300">
+                                    <span class="[&>*]:after:content-['_/_']">
+                                        <template v-for="column in columnGroup">
+                                            <span
+                                                class="after:content-['/'] last:after:content-['']"
+                                                :class="{
+                                                    'font-medium text-gray-800 dark:text-white': applied.sort.column == column,
+                                                    'cursor-pointer hover:text-gray-800 dark:hover:text-white': available.columns.find(c => c.index === column)?.sortable,
+                                                }"
+                                                @click="available.columns.find(c => c.index === column)?.sortable ? sort(available.columns.find(c => c.index === column)) : {}"
+                                            >
+                                                @{{ available.columns.find(c => c.index === column)?.label }}
+                                            </span>
+                                        </template>
+                                    </span>
+
+                                    <i
+                                        class="align-text-bottom text-base text-gray-800 dark:text-white ltr:ml-1.5 rtl:mr-1.5"
+                                        :class="[applied.sort.order === 'asc' ? 'icon-down-stat' : 'icon-up-stat']"
+                                        v-if="columnGroup.includes(applied.sort.column)"
+                                    ></i>
+                                </p>
+                            </div>
+                        </div>
+                    </template>
+                </template>
+
+                <template #body="{
+                    isLoading,
+                    available,
+                    applied,
+                    selectAll,
+                    sort,
+                    performAction
+                }">
+                    <template v-if="isLoading">
+                        <x-admin::shimmer.datagrid.table.body :isMultiRow="true" />
+                    </template>
+
+                    <template v-else>
+                        <div
+                            class="row grid grid-cols-1 gap-2 md:grid-cols-[2fr_1fr_minmax(150px,_3fr)_0.5fr] md:gap-0 border-b px-4 py-2.5 transition-all hover:bg-gray-50 dark:border-gray-800 dark:hover:bg-gray-950 min-w-full"
+                            v-for="record in available.records"
+                        >
+                            <!-- Customer, Product, Status -->
+                            <div class="flex gap-2.5">
+                                @if ($hasPermission)
+                                    <input
+                                        type="checkbox"
+                                        :id="`mass_action_select_record_${record.question_id}`"
+                                        class="peer hidden"
+                                        :name="`mass_action_select_record_${record.question_id}`"
+                                        :value="record.question_id"
+                                        v-model="applied.massActions.indices"
+                                        @change="setCurrentSelectionMode"
+                                    >
+
+                                    <label
+                                        class="icon-uncheckbox peer-checked:icon-checked cursor-pointer rounded-md text-2xl peer-checked:text-blue-600"
+                                        :for="`mass_action_select_record_${record.question_id}`"
+                                    ></label>
+                                @endif
+
+                                <div class="flex flex-col gap-1.5">
+                                    <p class="text-base font-semibold text-gray-800 dark:text-white">
+                                        @{{ record.customer_name }}
+                                    </p>
+
+                                    <p class="text-gray-600 dark:text-gray-300">
+                                        @{{ record.product_name }}
+                                    </p>
+
+                                    <p v-html="record.question_status"></p>
+                                </div>
+                            </div>
+
+                            <!-- Counts, Date, ID -->
+                            <div class="flex flex-col gap-1.5 ps-8 md:ps-0">
+                                <p class="text-gray-800 dark:text-white">
+                                    @{{ "@lang('admin::app.catalog.questions.index.datagrid.answers-label')".replace(':count', record.answers_count) }}
+                                </p>
+
+                                <p class="text-gray-600 dark:text-gray-300">
+                                    @{{ record.created_at }}
+                                </p>
+
+                                <p class="text-gray-600 dark:text-gray-300">
+                                    @{{ "#" + record.question_id }}
+                                </p>
+                            </div>
+
+                            <!-- Question text -->
+                            <div class="flex flex-col gap-1.5 ps-8 md:ps-0">
+                                <p class="text-gray-800 dark:text-white">
+                                    @{{ record.question }}
+                                </p>
+                            </div>
+
+                            <!-- Actions -->
+                            <div class="flex place-content-end items-center gap-1.5 self-center">
+                                <!-- 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 ltr:ml-1 rtl:mr-1"
+                                    ></span>
+                                </a>
+
+                                <!-- View Answers -->
+                                <a
+                                    v-if="record.actions.find(action => action.index === 'answers')"
+                                    :href="record.actions.find(action => action.index === 'answers')?.url"
+                                    class="cursor-pointer"
+                                    :title="'@lang('admin::app.catalog.questions.index.datagrid.view-answers')'"
+                                >
+                                    <span class="icon-comments cursor-pointer rounded-md p-1.5 text-2xl transition-all hover:bg-gray-200 dark:hover:bg-gray-800 ltr:ml-1 rtl:mr-1"></span>
+                                </a>
+
+                                <!-- Edit (status) -->
+                                <a
+                                    v-if="record.actions.find(action => action.index === 'edit')"
+                                    @click="edit(record.actions.find(action => action.index === 'edit')?.url)"
+                                >
+                                    <span class="icon-sort-right rtl:icon-sort-left cursor-pointer rounded-md p-1.5 text-2xl transition-all hover:bg-gray-200 dark:hover:bg-gray-800 ltr:ml-1 rtl:mr-1"></span>
+                                </a>
+                            </div>
+                        </div>
+                    </template>
+                </template>
+            </x-admin::datagrid>
+
+            <!-- Edit Drawer -->
+            <div class="flex flex-1 flex-col gap-2 max-xl:flex-auto">
+                <x-admin::form
+                    v-slot="{ meta, errors, handleSubmit }"
+                    as="div"
+                >
+                    <form
+                        @submit="handleSubmit($event, update)"
+                        ref="questionForm"
+                    >
+                        <x-admin::drawer ref="questionDrawer">
+                            <x-slot:header>
+                                <div class="flex items-center justify-between">
+                                    <p class="text-xl font-medium dark:text-white">
+                                        @lang('admin::app.catalog.questions.index.edit.title')
+                                    </p>
+
+                                    <button class="primary-button ltr:mr-11 rtl:ml-11">
+                                        @lang('admin::app.catalog.questions.index.edit.save-btn')
+                                    </button>
+                                </div>
+                            </x-slot>
+
+                            <x-slot:content>
+                                <div class="flex flex-col gap-4 px-1.5 py-2.5">
+                                    <div class="grid grid-cols-2 gap-4">
+                                        <div>
+                                            <p class="text-xs font-semibold text-gray-600 dark:text-gray-300">
+                                                @lang('admin::app.catalog.questions.index.edit.customer')
+                                            </p>
+                                            <p class="font-semibold text-gray-800 dark:text-white">
+                                                @{{ question.customer_name || 'N/A' }}
+                                            </p>
+                                        </div>
+
+                                        <div>
+                                            <p class="text-xs font-semibold text-gray-600 dark:text-gray-300">
+                                                @lang('admin::app.catalog.questions.index.edit.product')
+                                            </p>
+                                            <p class="font-semibold text-gray-800 dark:text-white">
+                                                @{{ question.product_name }}
+                                            </p>
+                                        </div>
+
+                                        <div>
+                                            <p class="text-xs font-semibold text-gray-600 dark:text-gray-300">
+                                                @lang('admin::app.catalog.questions.index.edit.id')
+                                            </p>
+                                            <p class="font-semibold text-gray-800 dark:text-white">
+                                                @{{ question.id }}
+                                            </p>
+                                        </div>
+
+                                        <div>
+                                            <p class="text-xs font-semibold text-gray-600 dark:text-gray-300">
+                                                @lang('admin::app.catalog.questions.index.edit.date')
+                                            </p>
+                                            <p class="font-semibold text-gray-800 dark:text-white">
+                                                @{{ question.created_at }}
+                                            </p>
+                                        </div>
+                                    </div>
+
+                                    <div class="w-full">
+                                        <p class="block text-xs font-semibold leading-6 text-gray-600 dark:text-gray-300">
+                                            @lang('admin::app.catalog.questions.index.edit.question-text')
+                                        </p>
+                                        <p class="text-gray-800 dark:text-white">
+                                            @{{ question.question }}
+                                        </p>
+                                    </div>
+
+                                    <div class="w-full">
+                                        <x-admin::form.control-group.control
+                                            type="hidden"
+                                            name="id"
+                                            rules="required"
+                                            ::value="question.id"
+                                        />
+
+                                        <x-admin::form.control-group>
+                                            <x-admin::form.control-group.label class="required">
+                                                @lang('admin::app.catalog.questions.index.edit.status')
+                                            </x-admin::form.control-group.label>
+
+                                            <x-admin::form.control-group.control
+                                                type="select"
+                                                name="status"
+                                                rules="required"
+                                                ::value="question.status"
+                                            >
+                                                <option value="pending">
+                                                    @lang('admin::app.catalog.questions.index.edit.pending')
+                                                </option>
+                                                <option value="approved">
+                                                    @lang('admin::app.catalog.questions.index.edit.approved')
+                                                </option>
+                                                <option value="rejected">
+                                                    @lang('admin::app.catalog.questions.index.edit.rejected')
+                                                </option>
+                                            </x-admin::form.control-group.control>
+
+                                            <x-admin::form.control-group.error control-name="status" />
+                                        </x-admin::form.control-group>
+                                    </div>
+                                </div>
+                            </x-slot>
+                        </x-admin::drawer>
+                    </form>
+                </x-admin::form>
+            </div>
+        </script>
+
+        <script type="module">
+            app.component('v-question-edit-drawer', {
+                template: '#v-question-edit-drawer-template',
+
+                data() {
+                    return {
+                        question: {},
+                    }
+                },
+
+                methods: {
+                    edit(url) {
+                        this.$axios.get(url)
+                            .then((response) => {
+                                this.question = response.data.data;
+                                this.$refs.questionDrawer.open();
+                            })
+                            .catch(error => {
+                                this.$emitter.emit('add-flash', { type: 'error', message: error.response?.data?.message || 'Error' });
+                            });
+                    },
+
+                    update(params) {
+                        let formData = new FormData(this.$refs.questionForm);
+                        formData.append('_method', 'put');
+
+                        this.$axios.post(`{{ route('admin.catalog.questions.update', '') }}/${params.id}`, formData)
+                            .then((response) => {
+                                this.$refs.questionDrawer.close();
+                                this.$refs.question_data.get();
+                                this.$emitter.emit('add-flash', { type: 'success', message: response.data.message });
+                            })
+                            .catch(error => {
+                                if (error.response?.status === 422) {
+                                    setErrors(error.response.data.errors);
+                                }
+                            });
+                    },
+                }
+            })
+        </script>
+    @endPushOnce
+</x-admin::layouts>

+ 132 - 0
packages/Webkul/Admin/src/Resources/views/reporting/search-logs/index.blade.php

@@ -0,0 +1,132 @@
+<x-admin::layouts>
+    <x-slot:title>
+        @lang('admin::app.reporting.search-logs.title')
+    </x-slot>
+
+    <div class="mb-5 flex items-center justify-between gap-4 max-sm:flex-wrap">
+        <p class="text-xl font-bold text-gray-800 dark:text-white">
+            @lang('admin::app.reporting.search-logs.title')
+        </p>
+    </div>
+
+    <v-search-log-report>
+        <div class="grid gap-4">
+            <div class="grid grid-cols-1 gap-4 md:grid-cols-3">
+                @foreach (range(1, 3) as $index)
+                    <div class="shimmer h-24 rounded-lg"></div>
+                @endforeach
+            </div>
+
+            <x-admin::shimmer.datagrid />
+        </div>
+    </v-search-log-report>
+
+    @pushOnce('scripts')
+        <script
+            type="text/x-template"
+            id="v-search-log-report-template"
+        >
+            <div class="grid gap-4">
+                <div class="flex justify-end gap-2 max-sm:flex-wrap">
+                    <input
+                        type="date"
+                        class="rounded-md border px-3 py-2 text-sm dark:border-gray-800 dark:bg-gray-900"
+                        v-model="filters.start"
+                        @change="getStats"
+                    >
+
+                    <input
+                        type="date"
+                        class="rounded-md border px-3 py-2 text-sm dark:border-gray-800 dark:bg-gray-900"
+                        v-model="filters.end"
+                        @change="getStats"
+                    >
+                </div>
+
+                <div class="grid grid-cols-1 gap-4 md:grid-cols-3">
+                    <div class="rounded-lg border bg-white p-5 dark:border-gray-800 dark:bg-gray-900">
+                        <p class="text-sm text-gray-600 dark:text-gray-300">
+                            @lang('admin::app.reporting.search-logs.total-searches')
+                        </p>
+                        <p class="mt-2 text-3xl font-bold">@{{ statistics.total }}</p>
+                    </div>
+
+                    <div class="rounded-lg border bg-white p-5 dark:border-gray-800 dark:bg-gray-900">
+                        <p class="text-sm text-gray-600 dark:text-gray-300">
+                            @lang('admin::app.reporting.search-logs.zero-results')
+                        </p>
+                        <p class="mt-2 text-3xl font-bold">@{{ statistics.zeroResult }}</p>
+                    </div>
+
+                    <div class="rounded-lg border bg-white p-5 dark:border-gray-800 dark:bg-gray-900">
+                        <p class="text-sm text-gray-600 dark:text-gray-300">
+                            @lang('admin::app.reporting.search-logs.corrected-searches')
+                        </p>
+                        <p class="mt-2 text-3xl font-bold">@{{ statistics.corrected }}</p>
+                    </div>
+                </div>
+
+                <div class="rounded-lg border bg-white p-5 dark:border-gray-800 dark:bg-gray-900">
+                    <p class="mb-3 font-semibold">
+                        @lang('admin::app.reporting.search-logs.top-queries')
+                    </p>
+
+                    <div
+                        v-if="statistics.topQueries.length"
+                        class="grid gap-2"
+                    >
+                        <div
+                            v-for="query in statistics.topQueries"
+                            class="flex justify-between border-b pb-2 last:border-0 dark:border-gray-800"
+                        >
+                            <span>@{{ query.query_text }}</span>
+                            <span>@{{ query.uses }}</span>
+                        </div>
+                    </div>
+
+                    <p v-else class="text-sm text-gray-500">
+                        @lang('admin::app.reporting.search-logs.no-records')
+                    </p>
+                </div>
+
+                <x-admin::datagrid :src="route('admin.reporting.search_logs.index')" />
+            </div>
+        </script>
+
+        <script type="module">
+            app.component('v-search-log-report', {
+                template: '#v-search-log-report-template',
+
+                data() {
+                    return {
+                        filters: {
+                            start: "{{ $startDate->format('Y-m-d') }}",
+                            end: "{{ $endDate->format('Y-m-d') }}",
+                        },
+
+                        statistics: {
+                            total: 0,
+                            zeroResult: 0,
+                            corrected: 0,
+                            topQueries: [],
+                        },
+                    };
+                },
+
+                mounted() {
+                    this.getStats();
+                },
+
+                methods: {
+                    getStats() {
+                        this.$axios.get("{{ route('admin.reporting.search_logs.stats') }}", {
+                            params: this.filters,
+                        }).then(({ data }) => {
+                            this.statistics = data.statistics;
+                        });
+                    },
+                },
+            });
+        </script>
+    @endPushOnce
+</x-admin::layouts>

+ 35 - 0
packages/Webkul/Admin/src/Routes/catalog-routes.php

@@ -11,6 +11,7 @@ use Webkul\Admin\Http\Controllers\Catalog\Product\GroupedController;
 use Webkul\Admin\Http\Controllers\Catalog\Product\SimpleController;
 use Webkul\Admin\Http\Controllers\Catalog\Product\VirtualController;
 use Webkul\Admin\Http\Controllers\Catalog\ProductController;
+use Webkul\Admin\Http\Controllers\Catalog\ProductQuestionController;
 
 /**
  * Catalog routes.
@@ -138,4 +139,38 @@ Route::prefix('catalog')->group(function () {
 
         Route::get('{id}/{attribute_id}', 'download')->name('admin.catalog.products.file.download');
     });
+
+    /**
+     * Product Q&A — Questions.
+     */
+    Route::controller(ProductQuestionController::class)->prefix('questions')->group(function () {
+        Route::get('', 'index')->name('admin.catalog.questions.index');
+
+        Route::get('edit/{id}', 'edit')->name('admin.catalog.questions.edit');
+
+        Route::put('edit/{id}', 'update')->name('admin.catalog.questions.update');
+
+        Route::delete('{id}', 'destroy')->name('admin.catalog.questions.destroy');
+
+        Route::post('mass-delete', 'massDestroy')->name('admin.catalog.questions.mass_destroy');
+
+        Route::post('mass-update', 'massUpdate')->name('admin.catalog.questions.mass_update');
+
+        /**
+         * Answers scoped under a question.
+         */
+        Route::get('{questionId}/answers', 'answers')->name('admin.catalog.questions.answers');
+
+        Route::post('{questionId}/answers', 'storeAnswer')->name('admin.catalog.questions.answers.store');
+
+        Route::get('answers/edit/{id}', 'editAnswer')->name('admin.catalog.questions.answers.edit');
+
+        Route::put('answers/edit/{id}', 'updateAnswer')->name('admin.catalog.questions.answers.update');
+
+        Route::delete('answers/{id}', 'destroyAnswer')->name('admin.catalog.questions.answers.destroy');
+
+        Route::post('answers/mass-delete', 'massDestroyAnswers')->name('admin.catalog.questions.answers.mass_destroy');
+
+        Route::post('answers/mass-update', 'massUpdateAnswers')->name('admin.catalog.questions.answers.mass_update');
+    });
 });

+ 7 - 0
packages/Webkul/Admin/src/Routes/reporting-routes.php

@@ -4,6 +4,7 @@ use Illuminate\Support\Facades\Route;
 use Webkul\Admin\Http\Controllers\Reporting\CustomerController;
 use Webkul\Admin\Http\Controllers\Reporting\ProductController;
 use Webkul\Admin\Http\Controllers\Reporting\SaleController;
+use Webkul\Admin\Http\Controllers\Reporting\SearchLogController;
 
 /**
  * Reporting routes.
@@ -53,4 +54,10 @@ Route::prefix('reporting')->group(function () {
 
         Route::get('view/stats', 'viewStats')->name('admin.reporting.sales.view.stats');
     });
+
+    Route::controller(SearchLogController::class)->prefix('search-logs')->group(function () {
+        Route::get('', 'index')->name('admin.reporting.search_logs.index');
+
+        Route::get('stats', 'stats')->name('admin.reporting.search_logs.stats');
+    });
 });

+ 62 - 0
packages/Webkul/Admin/tests/Feature/Reporting/SearchLogReportTest.php

@@ -0,0 +1,62 @@
+<?php
+
+use Webkul\Marketing\Models\SearchLog;
+
+use function Pest\Laravel\get;
+use function Pest\Laravel\getJson;
+
+it('shows the search log reporting page', function () {
+    $this->loginAsAdmin();
+
+    get(route('admin.reporting.search_logs.index'))
+        ->assertOk()
+        ->assertSeeText(trans('admin::app.reporting.search-logs.title'))
+        ->assertSeeText(trans('admin::app.reporting.search-logs.total-searches'))
+        ->assertSeeText(trans('admin::app.reporting.search-logs.zero-results'));
+});
+
+it('returns search log summary statistics for the requested period', function () {
+    $this->loginAsAdmin();
+
+    SearchLog::query()->create([
+        'search_text' => 'readyy',
+        'query_text'  => 'ready',
+        'results'     => 10,
+        'ip_address'  => '203.0.113.10',
+    ]);
+
+    SearchLog::query()->create([
+        'search_text' => 'missing',
+        'query_text'  => 'missing',
+        'results'     => 0,
+        'ip_address'  => '203.0.113.11',
+    ]);
+
+    get(route('admin.reporting.search_logs.stats', [
+        'start' => now()->subDay()->format('Y-m-d'),
+        'end'   => now()->addDay()->format('Y-m-d'),
+    ]))
+        ->assertOk()
+        ->assertJsonPath('statistics.total', 2)
+        ->assertJsonPath('statistics.zeroResult', 1)
+        ->assertJsonPath('statistics.corrected', 1);
+});
+
+it('returns searchable search log datagrid records', function () {
+    $this->loginAsAdmin();
+
+    $log = SearchLog::query()->create([
+        'search_text' => 'readyy',
+        'query_text'  => 'ready',
+        'results'     => 10,
+        'ip_address'  => '203.0.113.10',
+    ]);
+
+    getJson(route('admin.reporting.search_logs.index'), [
+        'X-Requested-With' => 'XMLHttpRequest',
+    ])
+        ->assertOk()
+        ->assertJsonPath('records.0.id', $log->id)
+        ->assertJsonPath('records.0.search_text', 'readyy')
+        ->assertJsonPath('records.0.query_text', 'ready');
+});

+ 87 - 3
packages/Webkul/BagistoApi/src/Dto/CartData.php

@@ -94,6 +94,62 @@ class CartData
     #[ApiProperty(description: 'Applied coupon code')]
     public ?string $couponCode = null;
 
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Applied gift card number')]
+    public ?string $giftcardNumber = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Gift card discount amount')]
+    public ?float $giftcardAmount = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Base gift card discount amount')]
+    public ?float $baseGiftcardAmount = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Formatted gift card discount amount')]
+    public ?string $formattedGiftcardAmount = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Remaining gift card balance after use')]
+    public ?float $remainingGiftcardAmount = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Formatted remaining gift card balance')]
+    public ?string $formattedRemainingGiftcardAmount = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'VIP member discount amount')]
+    public ?float $vipPlusAmount = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Base VIP member discount amount')]
+    public ?float $baseVipPlusAmount = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Formatted VIP member discount amount')]
+    public ?string $formattedVipPlusAmount = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'VIP discount amount (5%)')]
+    public ?float $vipDiscountAmount = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Base VIP discount amount (5%)')]
+    public ?float $baseVipDiscountAmount = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Formatted VIP discount amount (5%)')]
+    public ?string $formattedVipDiscountAmount = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Whether the current customer is a VIP member')]
+    public ?bool $isVip = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'VIP Plus expiration date')]
+    public ?string $vipExpireDate = null;
+
     #[Groups(['query', 'mutation'])]
     public ?bool $success = null;
 
@@ -260,9 +316,37 @@ class CartData
         $data->baseGrandTotal = (float) ($cart->base_grand_total ?? 0);
         $data->formattedGrandTotal = core()->currency($cart->base_grand_total ?? 0);
 
-        $additional = $cart->additional ?
-            (is_string($cart->additional) ? json_decode($cart->additional, true) : $cart->additional) : [];
-        $data->couponCode = $additional['coupon_code'] ?? null;
+        $data->couponCode = $cart->coupon_code;
+
+        // Gift card
+        $data->giftcardNumber = $cart->giftcard_number ?? null;
+        $data->giftcardAmount = (float) ($cart->giftcard_amount ?? 0);
+        $data->baseGiftcardAmount = (float) ($cart->base_giftcard_amount ?? 0);
+        $data->formattedGiftcardAmount = core()->currency($cart->base_giftcard_amount ?? 0);
+        $data->remainingGiftcardAmount = (float) ($cart->remaining_giftcard_amount ?? 0);
+        $data->formattedRemainingGiftcardAmount = core()->formatPrice($cart->remaining_giftcard_amount ?? 0);
+
+        // VIP member discount
+        $data->vipPlusAmount = (float) ($cart->vip_plus_amount ?? 0);
+        $data->baseVipPlusAmount = (float) ($cart->base_vip_plus_amount ?? 0);
+        $data->formattedVipPlusAmount = core()->currency($cart->base_vip_plus_amount ?? 0);
+
+        // VIP discount (5%)
+        $data->vipDiscountAmount = (float) ($cart->vip_discount_amount ?? 0);
+        $data->baseVipDiscountAmount = (float) ($cart->base_vip_discount_amount ?? 0);
+        $data->formattedVipDiscountAmount = core()->currency($cart->base_vip_discount_amount ?? 0);
+
+        // VIP status
+        $data->isVip = false;
+        $data->vipExpireDate = null;
+        if ($cart->customer_id) {
+            $customer = \Webkul\Customer\Models\Customer::find($cart->customer_id);
+            if ($customer && $customer->vip_expire_date) {
+                $expireDate = \Carbon\Carbon::parse($customer->vip_expire_date);
+                $data->isVip = !$expireDate->isPast();
+                $data->vipExpireDate = $expireDate->toIso8601String();
+            }
+        }
 
         if ($cart->billing_address) {
             $data->billingAddress = [

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

@@ -65,6 +65,10 @@ class CheckoutAddressOutput
     #[ApiProperty(readable: true, writable: false)]
     public ?string $billingPhoneNumber = null;
 
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public ?int $billingAddressId = null;
+
     // Shipping Address
     #[Groups(['query', 'mutation'])]
     #[ApiProperty(readable: true, writable: false)]
@@ -106,6 +110,10 @@ class CheckoutAddressOutput
     #[ApiProperty(readable: true, writable: false)]
     public ?string $shippingPhoneNumber = null;
 
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public ?int $shippingAddressId = null;
+
     #[Groups(['query', 'mutation'])]
     #[ApiProperty(readable: true, writable: false)]
     public ?bool $success = null;

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

@@ -0,0 +1,34 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use ApiPlatform\Metadata\ApiResource;
+use Symfony\Component\Serializer\Annotation\Groups;
+
+#[ApiResource]
+class CreateProductQuestionAnswerInput
+{
+    #[Groups(['mutation'])]
+    public int $questionId;
+
+    #[Groups(['mutation'])]
+    public string $answer;
+
+    #[Groups(['mutation'])]
+    public string $customerName;
+
+    #[Groups(['mutation'])]
+    public ?string $customerEmail = null;
+
+    public function __construct(
+        int $questionId,
+        string $answer,
+        string $customerName,
+        ?string $customerEmail = null,
+    ) {
+        $this->questionId = $questionId;
+        $this->answer = $answer;
+        $this->customerName = $customerName;
+        $this->customerEmail = $customerEmail;
+    }
+}

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

@@ -0,0 +1,34 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use ApiPlatform\Metadata\ApiResource;
+use Symfony\Component\Serializer\Annotation\Groups;
+
+#[ApiResource]
+class CreateProductQuestionInput
+{
+    #[Groups(['mutation'])]
+    public int $productId;
+
+    #[Groups(['mutation'])]
+    public string $question;
+
+    #[Groups(['mutation'])]
+    public string $customerName;
+
+    #[Groups(['mutation'])]
+    public ?string $customerEmail = null;
+
+    public function __construct(
+        int $productId,
+        string $question,
+        string $customerName,
+        ?string $customerEmail = null,
+    ) {
+        $this->productId = $productId;
+        $this->question = $question;
+        $this->customerName = $customerName;
+        $this->customerEmail = $customerEmail;
+    }
+}

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

@@ -36,6 +36,11 @@ class PaymentCallbackInput
     #[SerializedName('paymentSignature')]
     public ?string $paymentSignature = null;
 
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Internal verification token used to bind gateway authorization to this order')]
+    #[SerializedName('verifyToken')]
+    public ?string $verifyToken = null;
+
     /*
     | Optional address payload (express flow only)
     */

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

@@ -0,0 +1,20 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto\ProductSearch;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use Webkul\BagistoApi\Models\Product;
+
+#[ApiResource(
+    operations: [],
+    graphQlOperations: [],
+    paginationEnabled: false,
+)]
+class ProductSearchEdgeDto
+{
+    public ?string $cursor = null;
+
+    #[ApiProperty(readableLink: true)]
+    public ?Product $node = null;
+}

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

@@ -0,0 +1,17 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto\ProductSearch;
+
+use ApiPlatform\Metadata\ApiResource;
+
+#[ApiResource(operations: [], graphQlOperations: [])]
+class ProductSearchPageInfoDto
+{
+    public bool $hasNextPage = false;
+
+    public bool $hasPreviousPage = false;
+
+    public ?string $startCursor = null;
+
+    public ?string $endCursor = null;
+}

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

@@ -0,0 +1,28 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto\ProductSearch;
+
+use ApiPlatform\Metadata\ApiResource;
+
+#[ApiResource(operations: [], graphQlOperations: [])]
+class SearchMetadataDto
+{
+    public ?string $originalQuery = null;
+
+    public ?string $effectiveQuery = null;
+
+    public ?string $resultMessage = null;
+
+    public ?string $searchInsteadMessage = null;
+
+    public static function fromArray(array $metadata): self
+    {
+        $dto = new self;
+        $dto->originalQuery = $metadata['originalQuery'] ?? null;
+        $dto->effectiveQuery = $metadata['effectiveQuery'] ?? null;
+        $dto->resultMessage = $metadata['resultMessage'] ?? null;
+        $dto->searchInsteadMessage = $metadata['searchInsteadMessage'] ?? null;
+
+        return $dto;
+    }
+}

+ 10 - 0
packages/Webkul/BagistoApi/src/Dto/SaveCheckoutCartInput.php

@@ -30,4 +30,14 @@ class SaveCheckoutCartInput
     #[ApiProperty(description: 'Payment method code. Pass "-1" to leave unchanged.')]
     #[SerializedName('paymentMethod')]
     public ?string $paymentMethod = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Gift Card Number. Pass "-1" to leave unchanged.')]
+    #[SerializedName('giftCardNumber')]
+    public ?string $giftCardNumber = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Member discount toggle. Pass "-1" to leave unchanged, "1" to apply, empty string to remove.')]
+    #[SerializedName('memberDiscount')]
+    public ?string $memberDiscount = null;
 }

+ 9 - 1
packages/Webkul/BagistoApi/src/Exception/AuthenticationException.php

@@ -8,7 +8,7 @@ use ApiPlatform\Metadata\Exception\ProblemExceptionInterface;
 /**
  * Thrown when a request lacks credentials. Maps to HTTP 401 in REST.
  */
-class AuthenticationException extends \Exception implements \GraphQL\Error\ClientAware, HttpExceptionInterface, ProblemExceptionInterface
+class AuthenticationException extends \Exception implements \GraphQL\Error\ClientAware, HttpExceptionInterface, ProblemExceptionInterface, \GraphQL\Error\ProvidesExtensions
 {
     private int $status = 401;
 
@@ -18,6 +18,14 @@ class AuthenticationException extends \Exception implements \GraphQL\Error\Clien
     {
         return true;
     }
+    public function getExtensions(): array
+    {
+        return [
+            'code' => 'UNAUTHENTICATED',
+            'category' => $this->getCategory(),
+            'status' => $this->getStatus()
+        ];
+    }
 
     public function getCategory(): string
     {

+ 10 - 1
packages/Webkul/BagistoApi/src/Exception/InvalidInputException.php

@@ -19,7 +19,7 @@ use ApiPlatform\Metadata\Exception\ProblemExceptionInterface;
  *
  * Status Code: 400 Bad Request
  */
-class InvalidInputException extends \Exception implements \GraphQL\Error\ClientAware, HttpExceptionInterface, ProblemExceptionInterface
+class InvalidInputException extends \Exception implements \GraphQL\Error\ClientAware, HttpExceptionInterface, ProblemExceptionInterface, \GraphQL\Error\ProvidesExtensions
 {
     private int $status = 400;
 
@@ -30,6 +30,15 @@ class InvalidInputException extends \Exception implements \GraphQL\Error\ClientA
         return true;
     }
 
+    public function getExtensions(): array
+    {
+        return [
+            'code' => 'INVALID_INPUT',
+            'category' => $this->getCategory(),
+            'status' => $this->getStatus()
+        ];
+    }
+
     public function getCategory(): string
     {
         return 'invalid_input';

+ 9 - 1
packages/Webkul/BagistoApi/src/Exception/ResourceNotFoundException.php

@@ -9,7 +9,7 @@ use ApiPlatform\Metadata\Exception\ProblemExceptionInterface;
  * Thrown when a requested resource (cart, product, item, etc.) is not found.
  * Maps to HTTP 404 in REST and an `errors[]` entry in GraphQL.
  */
-class ResourceNotFoundException extends \Exception implements \GraphQL\Error\ClientAware, HttpExceptionInterface, ProblemExceptionInterface
+class ResourceNotFoundException extends \Exception implements \GraphQL\Error\ClientAware, HttpExceptionInterface, ProblemExceptionInterface, \GraphQL\Error\ProvidesExtensions
 {
     private int $status = 404;
 
@@ -19,6 +19,14 @@ class ResourceNotFoundException extends \Exception implements \GraphQL\Error\Cli
     {
         return true;
     }
+    public function getExtensions(): array
+    {
+        return [
+            'code' => 'RESOURCE_NOT_FOUND',
+            'category' => $this->getCategory(),
+            'status' => $this->getStatus()
+        ];
+    }
 
     public function getCategory(): string
     {

+ 46 - 0
packages/Webkul/BagistoApi/src/GraphQl/Serializer/FixedSerializerContextBuilder.php

@@ -15,6 +15,8 @@ use Webkul\BagistoApi\Models\CustomerInvoice;
 use Webkul\BagistoApi\Models\CustomerInvoiceAddress;
 use Webkul\BagistoApi\Models\CustomerOrderShipment;
 use Webkul\BagistoApi\Models\CustomerOrderShipmentItem;
+use Webkul\BagistoApi\Models\Product;
+use Webkul\BagistoApi\Models\ProductSearch;
 
 /**
  * Decorates the GraphQL SerializerContextBuilder to fix attribute resolution
@@ -39,6 +41,21 @@ class FixedSerializerContextBuilder implements SerializerContextBuilderInterface
     {
         $context = $this->decorated->create($resourceClass, $operation, $resolverContext, $normalization);
 
+        /**
+         * ProductSearch is a wrapper object that intentionally contains an
+         * `edges` field. API Platform otherwise mistakes it for a collection
+         * connection and flattens the selection to edges.node, dropping the
+         * wrapper's totalCount, pageInfo, and searchMetadata fields.
+         */
+        if ($normalization && $resourceClass === ProductSearch::class) {
+            $fields = $resolverContext['fields']
+                ?? (($resolverContext['info'] ?? null) instanceof ResolveInfo
+                    ? $resolverContext['info']->getFieldSelection(\PHP_INT_MAX)
+                    : []);
+
+            $context['attributes'] = $this->productSearchAttributes($fields);
+        }
+
         // Ensure nested CustomerOrderItem fields are always included
         // This handles the case where nested items don't trigger their own context builder call
         if ($normalization && $resourceClass === CustomerOrder::class) {
@@ -127,6 +144,35 @@ class FixedSerializerContextBuilder implements SerializerContextBuilderInterface
         return $context;
     }
 
+    private function productSearchAttributes(array $fields): array
+    {
+        $attributes = [];
+
+        foreach ($fields as $field => $selection) {
+            $property = $this->nameConverter
+                ? $this->nameConverter->denormalize((string) $field)
+                : $field;
+
+            if ($field === 'edges' && is_array($selection)) {
+                $edgeSelection = $this->replaceIdKeys($selection, ProductSearch::class, false);
+
+                if (isset($selection['node']) && is_array($selection['node'])) {
+                    $edgeSelection['node'] = $this->replaceIdKeys($selection['node'], Product::class);
+                }
+
+                $attributes[$property] = $edgeSelection;
+
+                continue;
+            }
+
+            $attributes[$property] = is_array($selection)
+                ? $this->replaceIdKeys($selection, ProductSearch::class, false)
+                : $selection;
+        }
+
+        return $attributes;
+    }
+
     /**
      * Ensure nested items' attributes include qty fields
      */

+ 45 - 0
packages/Webkul/BagistoApi/src/Http/Middleware/SearchMetadataResponse.php

@@ -0,0 +1,45 @@
+<?php
+
+namespace Webkul\BagistoApi\Http\Middleware;
+
+use Closure;
+use Illuminate\Http\Request;
+use Symfony\Component\HttpFoundation\JsonResponse;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * Adds effective Elasticsearch search details to collection responses.
+ */
+class SearchMetadataResponse
+{
+    public function handle(Request $request, Closure $next): Response
+    {
+        $response = $next($request);
+        $metadata = $request->attributes->get('bagistoapi.search_metadata');
+
+        if (! is_array($metadata) || ! $response instanceof JsonResponse) {
+            return $response;
+        }
+
+        $payload = json_decode((string) $response->getContent(), true);
+
+        if (! is_array($payload)) {
+            return $response;
+        }
+
+        if ($this->isGraphQlRequest($request)) {
+            $payload['extensions']['searchMetadata'] = $metadata;
+        } else {
+            $payload['meta']['searchMetadata'] = $metadata;
+        }
+
+        $response->setData($payload);
+
+        return $response;
+    }
+
+    private function isGraphQlRequest(Request $request): bool
+    {
+        return $request->is('api/graphql', 'graphql');
+    }
+}

+ 10 - 6
packages/Webkul/BagistoApi/src/Jobs/ReconcilePendingPaymentJob.php

@@ -11,6 +11,7 @@ use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Event;
 use Illuminate\Support\Facades\Log;
 use Webkul\Paypal\Payment\SmartButton;
+use Webkul\BagistoApi\Services\PaymentService;
 use Webkul\Sales\Models\Order;
 use Webkul\Sales\Models\OrderProxy;
 use Webkul\Sales\Repositories\OrderRepository;
@@ -52,7 +53,7 @@ class ReconcilePendingPaymentJob implements ShouldQueue
         if (! $order) {
             return;
         }
-     
+
         if (! in_array($order->status, [Order::STATUS_PENDING, Order::STATUS_PENDING_PAYMENT], true)) {
             return;
         }
@@ -68,10 +69,14 @@ class ReconcilePendingPaymentJob implements ShouldQueue
         }
 
         try {
-            $gatewayOrder = app(SmartButton::class)->getOrder($gatewayOrderId);
-            $status = (string) ($gatewayOrder->result->status ?? '');
-            $captures = $gatewayOrder->result->purchase_units[0]->payments->captures ?? [];
-
+            $gatewayHandler = app(PaymentService::class)->resolveGatewayHandler($method, $order);
+            if ($gatewayHandler && method_exists($gatewayHandler, 'fetchOrderStatus')) {
+                list($captures, $status) = $gatewayHandler->fetchOrderStatus($gatewayOrderId);
+            } else {
+                $gatewayOrder = app(SmartButton::class)->getOrder($gatewayOrderId);
+                $status = (string) ($gatewayOrder->result->status ?? '');
+                $captures = $gatewayOrder->result->purchase_units[0]->payments->captures ?? [];
+            }
             if (! empty($captures) || in_array(strtoupper($status), ['COMPLETED', 'CAPTURED'], true)) {
                 /*
                  * Capture exists but Bagisto is still pending - a
@@ -89,7 +94,6 @@ class ReconcilePendingPaymentJob implements ShouldQueue
             if (in_array(strtoupper($status), ['VOIDED', 'EXPIRED', 'PAYER_ACTION_REQUIRED'], true)) {
                 Event::dispatch('bagistoapi.payment.reconcile.voided', $order);
             }
-
             $cancelled = DB::transaction(function () use ($orderRepository, $order, $gatewayOrderId): bool {
                 /*
                  * Re-lock and re-check inside a transaction right before

+ 2 - 0
packages/Webkul/BagistoApi/src/Models/PaymentAttempt.php

@@ -27,6 +27,8 @@ class PaymentAttempt extends Model implements PaymentAttemptContract
 
     public const ACTION_CALLBACK = 'callback';
 
+    public const ACTION_WEBHOOK = 'webhook';
+
     /**
      * Status of the attempt.
      */

+ 16 - 0
packages/Webkul/BagistoApi/src/Models/Product.php

@@ -128,6 +128,13 @@ use ApiPlatform\OpenApi\Model\Operation;
                         required: false,
                         schema: ['type' => 'string'],
                     ),
+                    new \ApiPlatform\OpenApi\Model\Parameter(
+                        name: 'suggest',
+                        in: 'query',
+                        description: 'Set to 1 to enable Elasticsearch fuzzy matching, typo correction, and zero-result query fallback.',
+                        required: false,
+                        schema: ['type' => 'integer', 'enum' => [1], 'example' => 1],
+                    ),
                     new \ApiPlatform\OpenApi\Model\Parameter(
                         name: 'sort',
                         in: 'query',
@@ -305,6 +312,10 @@ use ApiPlatform\OpenApi\Model\Operation;
                     'type'        => 'String',
                     'description' => 'Search query to filter products by SKU or name',
                 ],
+                'suggest' => [
+                    'type'        => 'Boolean',
+                    'description' => 'Enable Elasticsearch fuzzy matching, typo correction, and zero-result query fallback.',
+                ],
                 'filter' => [
                     'type'        => 'String',
                     'description' => 'JSON filter object containing attribute filters (type, sku, category_id, price, color, name, etc.). Example: {"type":"configurable","sku":"ABC123"}',
@@ -1870,6 +1881,11 @@ class Product extends BaseProduct
         return $this->hasMany(ProductReview::class);
     }
 
+    public function questions(): HasMany
+    {
+        return $this->hasMany(ProductQuestion::class);
+    }
+
     // ========================================
     // Helper Methods
     // ========================================

+ 265 - 0
packages/Webkul/BagistoApi/src/Models/ProductQuestion.php

@@ -0,0 +1,265 @@
+<?php
+
+namespace Webkul\BagistoApi\Models;
+
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Delete;
+use ApiPlatform\Metadata\Get;
+use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\Metadata\Link;
+use ApiPlatform\Metadata\Patch;
+use ApiPlatform\Metadata\Post;
+use ApiPlatform\Metadata\GraphQl\DeleteMutation;
+use ApiPlatform\Metadata\GraphQl\Mutation;
+use ApiPlatform\Metadata\GraphQl\Query;
+use ApiPlatform\Metadata\GraphQl\QueryCollection;
+use ApiPlatform\OpenApi\Model\Operation;
+use Webkul\BagistoApi\Dto\CreateProductQuestionInput;
+use Webkul\BagistoApi\Resolver\BaseQueryItemResolver;
+use Webkul\BagistoApi\State\ProductQuestionProvider;
+use Webkul\BagistoApi\State\ProductQuestionProcessor;
+
+/**
+ * Product Q&A — Questions resource
+ *
+ * Storefront: list approved questions, post new questions.
+ * Admin: update status (approve/reject), delete.
+ */
+#[ApiResource(
+    routePrefix: '/api/shop',
+    shortName: 'ProductQuestion',
+    uriTemplate: '/questions',
+    operations: [
+        new GetCollection(
+            uriTemplate: '/questions',
+            provider: ProductQuestionProvider::class,
+            openapi: new Operation(
+                tags: ['Product Q&A'],
+                summary: 'List product questions',
+                description: 'Returns product questions. Defaults to `approved` status. Supports `product_id`, `status`, `page`, `per_page` filters.',
+                parameters: [
+                    new \ApiPlatform\OpenApi\Model\Parameter(name: 'product_id', in: 'query', required: false, schema: ['type' => 'integer']),
+                    new \ApiPlatform\OpenApi\Model\Parameter(name: 'status', in: 'query', required: false, schema: ['type' => 'string', 'enum' => ['pending', 'approved', 'rejected'], 'default' => 'approved']),
+                    new \ApiPlatform\OpenApi\Model\Parameter(name: 'page', in: 'query', required: false, schema: ['type' => 'integer', 'default' => 1]),
+                    new \ApiPlatform\OpenApi\Model\Parameter(name: 'per_page', in: 'query', required: false, schema: ['type' => 'integer', 'default' => 30, 'maximum' => 50]),
+                    new \ApiPlatform\OpenApi\Model\Parameter(
+                        name: 'with_answers', in: 'query', required: false,
+                        description: 'Embed top approved answers inside each question. Default: `true`. Pass `false` to skip.',
+                        schema: ['type' => 'boolean', 'default' => true],
+                    ),
+                    new \ApiPlatform\OpenApi\Model\Parameter(
+                        name: 'answers_per_question', in: 'query', required: false,
+                        description: 'How many answers to embed per question (1–20). Default: 5. Pinned answers appear first, then sorted by `useful_count` descending.',
+                        schema: ['type' => 'integer', 'default' => 5, 'minimum' => 1, 'maximum' => 20],
+                    ),
+                    new \ApiPlatform\OpenApi\Model\Parameter(
+                        name: 'answer_status', in: 'query', required: false,
+                        description: 'Status of embedded answers to return. Default: `approved`.',
+                        schema: ['type' => 'string', 'enum' => ['approved', 'pending', 'rejected'], 'default' => 'approved'],
+                    ),
+                ],
+            ),
+        ),
+        new Get(
+            uriTemplate: '/questions/{id}',
+            openapi: new Operation(
+                tags: ['Product Q&A'],
+                summary: 'Get a single question by ID',
+            ),
+        ),
+        new Post(
+            uriTemplate: '/questions',
+            processor: ProductQuestionProcessor::class,
+            denormalizationContext: ['groups' => ['mutation'], 'allow_extra_attributes' => true],
+            openapi: new Operation(
+                tags: ['Product Q&A'],
+                summary: 'Submit a product question',
+                description: 'Submit a question about a product. **Requires authentication** (Bearer token). New questions start in `pending` status pending admin approval.',
+                requestBody: new \ApiPlatform\OpenApi\Model\RequestBody(
+                    required: true,
+                    content: new \ArrayObject([
+                        'application/json' => [
+                            'schema' => [
+                                'type'       => 'object',
+                                'required'   => ['product_id', 'question', 'customer_name'],
+                                'properties' => [
+                                    'product_id'    => ['type' => 'integer', 'example' => 1],
+                                    'question'      => ['type' => 'string', 'example' => 'Does this come in blue?'],
+                                    'customer_name' => ['type' => 'string', 'example' => 'Jane Doe'],
+                                    'customer_email'=> ['type' => 'string', 'format' => 'email', 'example' => 'jane@example.com'],
+                                ],
+                            ],
+                        ],
+                    ]),
+                ),
+                responses: [
+                    '201' => new \ApiPlatform\OpenApi\Model\Response(description: 'Question submitted. Starts in `pending` status.'),
+                    '422' => new \ApiPlatform\OpenApi\Model\Response(description: 'Validation error.'),
+                ],
+            ),
+        ),
+        new Patch(
+            uriTemplate: '/questions/{id}',
+            processor: ProductQuestionProcessor::class,
+            denormalizationContext: ['groups' => ['mutation'], 'allow_extra_attributes' => true],
+            openapi: new Operation(
+                tags: ['Product Q&A — Admin'],
+                summary: 'Update question status (admin)',
+                description: 'Approve or reject a question. Intended for admin use.',
+                requestBody: new \ApiPlatform\OpenApi\Model\RequestBody(
+                    required: true,
+                    content: new \ArrayObject([
+                        'application/merge-patch+json' => [
+                            'schema' => [
+                                'type'       => 'object',
+                                'properties' => [
+                                    'status' => ['type' => 'string', 'enum' => ['pending', 'approved', 'rejected']],
+                                ],
+                            ],
+                        ],
+                    ]),
+                ),
+            ),
+        ),
+        new Delete(
+            uriTemplate: '/questions/{id}',
+            processor: ProductQuestionProcessor::class,
+            openapi: new Operation(
+                tags: ['Product Q&A — Admin'],
+                summary: 'Delete a question',
+                responses: [
+                    '204' => new \ApiPlatform\OpenApi\Model\Response(description: 'Deleted.'),
+                    '404' => new \ApiPlatform\OpenApi\Model\Response(description: 'Not found.'),
+                ],
+            ),
+        ),
+    ],
+    graphQlOperations: [
+        new QueryCollection(
+            provider: ProductQuestionProvider::class,
+            args: [
+                'product_id' => ['type' => 'Int', 'description' => 'Filter by product ID'],
+                'status'     => ['type' => 'String', 'description' => 'Filter by status (approved/pending/rejected)'],
+                'first'      => ['type' => 'Int'],
+                'last'       => ['type' => 'Int'],
+                'after'      => ['type' => 'String'],
+                'before'     => ['type' => 'String'],
+            ]
+        ),
+        new Query(resolver: BaseQueryItemResolver::class),
+        new Mutation(
+            name: 'create',
+            input: CreateProductQuestionInput::class,
+            output: self::class,
+            processor: ProductQuestionProcessor::class,
+        ),
+        new Mutation(
+            name: 'updateStatus',
+            processor: ProductQuestionProcessor::class,
+            description: 'Update question status (admin)',
+        ),
+        new DeleteMutation(
+            name: 'delete',
+            description: 'Delete a question',
+        ),
+    ]
+)]
+#[ApiResource(
+    routePrefix: '/api/shop',
+    shortName: 'ProductQuestion',
+    uriTemplate: '/products/{productId}/questions',
+    uriVariables: [
+        'productId' => new Link(
+            fromClass: Product::class,
+            fromProperty: 'questions',
+            identifiers: ['id']
+        ),
+    ],
+    operations: [
+        new GetCollection(
+            provider: ProductQuestionProvider::class,
+            openapi: new Operation(
+                tags: ['Product Q&A'],
+                summary: 'List approved questions for a product',
+                parameters: [
+                    new \ApiPlatform\OpenApi\Model\Parameter(name: 'status', in: 'query', required: false, schema: ['type' => 'string', 'default' => 'approved']),
+                    new \ApiPlatform\OpenApi\Model\Parameter(name: 'page', in: 'query', required: false, schema: ['type' => 'integer', 'default' => 1]),
+                    new \ApiPlatform\OpenApi\Model\Parameter(name: 'per_page', in: 'query', required: false, schema: ['type' => 'integer', 'default' => 30, 'maximum' => 50]),
+                    new \ApiPlatform\OpenApi\Model\Parameter(
+                        name: 'with_answers', in: 'query', required: false,
+                        description: 'Embed top approved answers inside each question. Default: `true`.',
+                        schema: ['type' => 'boolean', 'default' => true],
+                    ),
+                    new \ApiPlatform\OpenApi\Model\Parameter(
+                        name: 'answers_per_question', in: 'query', required: false,
+                        description: 'Max answers embedded per question (1–20). Default: 5.',
+                        schema: ['type' => 'integer', 'default' => 5, 'minimum' => 1, 'maximum' => 20],
+                    ),
+                    new \ApiPlatform\OpenApi\Model\Parameter(
+                        name: 'answer_status', in: 'query', required: false,
+                        description: 'Status of embedded answers. Default: `approved`.',
+                        schema: ['type' => 'string', 'enum' => ['approved', 'pending', 'rejected'], 'default' => 'approved'],
+                    ),
+                ],
+            ),
+        ),
+    ],
+    graphQlOperations: []
+)]
+class ProductQuestion extends \Webkul\Product\Models\ProductQuestion
+{
+    protected $fillable = [
+        'product_id',
+        'customer_id',
+        'customer_name',
+        'customer_email',
+        'question',
+        'status',
+        'answers_count',
+    ];
+
+    protected $casts = [
+        'id'            => 'int',
+        'product_id'    => 'int',
+        'customer_id'   => 'int',
+        'answers_count' => 'int',
+        'question'      => 'string',
+        'customer_name' => 'string',
+        'status'        => 'string',
+        'created_at'    => 'datetime',
+        'updated_at'    => 'datetime',
+    ];
+
+    public function __get($key)
+    {
+        if ($this->hasAttribute($key)) {
+            return $this->getAttribute($key);
+        }
+
+        return parent::__get($key);
+    }
+
+    public function __isset($key)
+    {
+        if ($this->hasAttribute($key)) {
+            return true;
+        }
+
+        return parent::__isset($key);
+    }
+
+    public function __set($key, $value)
+    {
+        $own = ['id', 'product_id', 'customer_id', 'customer_name', 'customer_email', 'question', 'status', 'answers_count', 'created_at', 'updated_at'];
+        if (in_array($key, $own)) {
+            $this->setAttribute($key, $value);
+        } else {
+            parent::__set($key, $value);
+        }
+    }
+
+    public function getId(): int
+    {
+        return (int) $this->getAttribute('id');
+    }
+}

+ 305 - 0
packages/Webkul/BagistoApi/src/Models/ProductQuestionAnswer.php

@@ -0,0 +1,305 @@
+<?php
+
+namespace Webkul\BagistoApi\Models;
+
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Delete;
+use ApiPlatform\Metadata\Get;
+use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\Metadata\Link;
+use ApiPlatform\Metadata\Patch;
+use ApiPlatform\Metadata\Post;
+use ApiPlatform\Metadata\GraphQl\DeleteMutation;
+use ApiPlatform\Metadata\GraphQl\Mutation;
+use ApiPlatform\Metadata\GraphQl\Query;
+use ApiPlatform\Metadata\GraphQl\QueryCollection;
+use ApiPlatform\OpenApi\Model\Operation;
+use Webkul\BagistoApi\Dto\CreateProductQuestionAnswerInput;
+use Webkul\BagistoApi\Resolver\BaseQueryItemResolver;
+use Webkul\BagistoApi\State\ProductQuestionAnswerProvider;
+use Webkul\BagistoApi\State\ProductQuestionAnswerProcessor;
+
+/**
+ * Product Q&A — Answers resource
+ *
+ * Storefront:
+ *   - List approved answers (sorted: pinned first, then by useful_count)
+ *   - Submit an answer (pending approval)
+ *   - POST /api/shop/answers/{id}/vote  → mark answer as "useful"
+ *
+ * Admin:
+ *   - PATCH status (approve/reject)
+ *   - PATCH is_pinned (pin/unpin best answer)
+ *   - DELETE
+ */
+#[ApiResource(
+    routePrefix: '/api/shop',
+    shortName: 'ProductQuestionAnswer',
+    uriTemplate: '/answers',
+    operations: [
+        new GetCollection(
+            uriTemplate: '/answers',
+            provider: ProductQuestionAnswerProvider::class,
+            openapi: new Operation(
+                tags: ['Product Q&A'],
+                summary: 'List answers',
+                description: 'Returns answers. Defaults to `approved` status. Supports `question_id`, `status`, `page`, `per_page` filters. Results are sorted: pinned answers first, then by `useful_count` descending.',
+                parameters: [
+                    new \ApiPlatform\OpenApi\Model\Parameter(name: 'question_id', in: 'query', required: false, schema: ['type' => 'integer']),
+                    new \ApiPlatform\OpenApi\Model\Parameter(name: 'status', in: 'query', required: false, schema: ['type' => 'string', 'enum' => ['pending', 'approved', 'rejected'], 'default' => 'approved']),
+                    new \ApiPlatform\OpenApi\Model\Parameter(name: 'page', in: 'query', required: false, schema: ['type' => 'integer', 'default' => 1]),
+                    new \ApiPlatform\OpenApi\Model\Parameter(name: 'per_page', in: 'query', required: false, schema: ['type' => 'integer', 'default' => 30, 'maximum' => 50]),
+                ],
+            ),
+        ),
+        new Get(
+            uriTemplate: '/answers/{id}',
+            openapi: new Operation(
+                tags: ['Product Q&A'],
+                summary: 'Get a single answer by ID',
+            ),
+        ),
+        new Post(
+            uriTemplate: '/answers',
+            processor: ProductQuestionAnswerProcessor::class,
+            denormalizationContext: ['groups' => ['mutation'], 'allow_extra_attributes' => true],
+            openapi: new Operation(
+                tags: ['Product Q&A'],
+                summary: 'Submit an answer to a question',
+                description: 'Any visitor (guest or logged-in customer) can answer an approved question. New answers start in `pending` status.',
+                requestBody: new \ApiPlatform\OpenApi\Model\RequestBody(
+                    required: true,
+                    content: new \ArrayObject([
+                        'application/json' => [
+                            'schema' => [
+                                'type'       => 'object',
+                                'required'   => ['question_id', 'answer', 'customer_name'],
+                                'properties' => [
+                                    'question_id'   => ['type' => 'integer', 'example' => 1],
+                                    'answer'        => ['type' => 'string', 'example' => 'Yes, it comes in blue, red, and green.'],
+                                    'customer_name' => ['type' => 'string', 'example' => 'Support Team'],
+                                    'customer_email'=> ['type' => 'string', 'format' => 'email'],
+                                ],
+                            ],
+                        ],
+                    ]),
+                ),
+                responses: [
+                    '201' => new \ApiPlatform\OpenApi\Model\Response(description: 'Answer submitted. Starts in `pending` status.'),
+                    '422' => new \ApiPlatform\OpenApi\Model\Response(description: 'Validation error.'),
+                ],
+            ),
+        ),
+        new Patch(
+            uriTemplate: '/answers/{id}',
+            processor: ProductQuestionAnswerProcessor::class,
+            denormalizationContext: ['groups' => ['mutation'], 'allow_extra_attributes' => true],
+            openapi: new Operation(
+                tags: ['Product Q&A — Admin'],
+                summary: 'Update answer status or pin (admin)',
+                description: 'Approve/reject an answer or toggle its pinned state. When `is_pinned: true`, all other answers for the same question are automatically unpinned.',
+                requestBody: new \ApiPlatform\OpenApi\Model\RequestBody(
+                    required: true,
+                    content: new \ArrayObject([
+                        'application/merge-patch+json' => [
+                            'schema' => [
+                                'type'       => 'object',
+                                'properties' => [
+                                    'status'    => ['type' => 'string', 'enum' => ['pending', 'approved', 'rejected']],
+                                    'is_pinned' => ['type' => 'boolean', 'description' => 'Pin this answer to the top. Unpins all other answers for the same question.'],
+                                ],
+                            ],
+                            'example' => ['status' => 'approved', 'is_pinned' => true],
+                        ],
+                    ]),
+                ),
+                responses: [
+                    '200' => new \ApiPlatform\OpenApi\Model\Response(description: 'Answer updated.'),
+                    '404' => new \ApiPlatform\OpenApi\Model\Response(description: 'Answer not found.'),
+                ],
+            ),
+        ),
+        new Delete(
+            uriTemplate: '/answers/{id}',
+            processor: ProductQuestionAnswerProcessor::class,
+            openapi: new Operation(
+                tags: ['Product Q&A — Admin'],
+                summary: 'Delete an answer',
+                responses: [
+                    '204' => new \ApiPlatform\OpenApi\Model\Response(description: 'Deleted.'),
+                    '404' => new \ApiPlatform\OpenApi\Model\Response(description: 'Not found.'),
+                ],
+            ),
+        ),
+        new Post(
+            uriTemplate: '/answers/{id}/vote',
+            processor: ProductQuestionAnswerProcessor::class,
+            denormalizationContext: ['groups' => ['mutation'], 'allow_extra_attributes' => true],
+            openapi: new Operation(
+                tags: ['Product Q&A'],
+                summary: 'Vote an answer as "useful"',
+                description: '**Requires authentication** (Bearer token). Marks an approved answer as useful. One vote per customer — duplicate votes are silently ignored.',
+                responses: [
+                    '200' => new \ApiPlatform\OpenApi\Model\Response(
+                        description: 'Vote recorded (or duplicate detected).',
+                        content: new \ArrayObject([
+                            'application/json' => [
+                                'example' => ['voted' => true, 'useful_count' => 12, 'message' => 'Thank you for your feedback.'],
+                            ],
+                        ]),
+                    ),
+                    '404' => new \ApiPlatform\OpenApi\Model\Response(description: 'Answer not found.'),
+                ],
+            ),
+        ),
+    ],
+    graphQlOperations: [
+        new QueryCollection(
+            provider: ProductQuestionAnswerProvider::class,
+            args: [
+                'question_id' => ['type' => 'Int', 'description' => 'Filter by question ID'],
+                'status'      => ['type' => 'String', 'description' => 'Filter by status'],
+                'first'       => ['type' => 'Int'],
+                'last'        => ['type' => 'Int'],
+                'after'       => ['type' => 'String'],
+                'before'      => ['type' => 'String'],
+            ]
+        ),
+        new Query(resolver: BaseQueryItemResolver::class),
+        new Mutation(
+            name: 'create',
+            input: CreateProductQuestionAnswerInput::class,
+            output: self::class,
+            processor: ProductQuestionAnswerProcessor::class,
+        ),
+        new Mutation(
+            name: 'updateAnswer',
+            processor: ProductQuestionAnswerProcessor::class,
+            description: 'Update answer status or pin (admin)',
+        ),
+        new Mutation(
+            name: 'voteUseful',
+            processor: ProductQuestionAnswerProcessor::class,
+            description: 'Vote an answer as useful',
+        ),
+        new DeleteMutation(
+            name: 'delete',
+            description: 'Delete an answer',
+        ),
+    ]
+)]
+#[ApiResource(
+    routePrefix: '/api/shop',
+    shortName: 'ProductQuestionAnswer',
+    uriTemplate: '/questions/{questionId}/answers',
+    uriVariables: [
+        'questionId' => new Link(
+            fromClass: ProductQuestion::class,
+            fromProperty: 'answers',
+            identifiers: ['id']
+        ),
+    ],
+    operations: [
+        new GetCollection(
+            provider: ProductQuestionAnswerProvider::class,
+            openapi: new Operation(
+                tags: ['Product Q&A'],
+                summary: 'List approved answers for a question',
+                description: 'Returns approved answers for the given question, sorted by pinned status first, then `useful_count` descending.',
+                parameters: [
+                    new \ApiPlatform\OpenApi\Model\Parameter(name: 'status', in: 'query', required: false, schema: ['type' => 'string', 'default' => 'approved']),
+                    new \ApiPlatform\OpenApi\Model\Parameter(name: 'page', in: 'query', required: false, schema: ['type' => 'integer', 'default' => 1]),
+                    new \ApiPlatform\OpenApi\Model\Parameter(name: 'per_page', in: 'query', required: false, schema: ['type' => 'integer', 'default' => 30, 'maximum' => 50]),
+                ],
+            ),
+        ),
+        new Post(
+            uriTemplate: '/questions/{questionId}/answers',
+            processor: ProductQuestionAnswerProcessor::class,
+            denormalizationContext: ['groups' => ['mutation'], 'allow_extra_attributes' => true],
+            openapi: new Operation(
+                tags: ['Product Q&A'],
+                summary: 'Submit an answer to a specific question',
+                requestBody: new \ApiPlatform\OpenApi\Model\RequestBody(
+                    required: true,
+                    content: new \ArrayObject([
+                        'application/json' => [
+                            'schema' => [
+                                'type'       => 'object',
+                                'required'   => ['answer', 'customer_name'],
+                                'properties' => [
+                                    'answer'        => ['type' => 'string', 'example' => 'Yes, it comes in blue.'],
+                                    'customer_name' => ['type' => 'string', 'example' => 'Support Team'],
+                                    'customer_email'=> ['type' => 'string', 'format' => 'email'],
+                                ],
+                            ],
+                        ],
+                    ]),
+                ),
+                responses: [
+                    '201' => new \ApiPlatform\OpenApi\Model\Response(description: 'Answer submitted.'),
+                ],
+            ),
+        ),
+    ],
+    graphQlOperations: []
+)]
+class ProductQuestionAnswer extends \Webkul\Product\Models\ProductQuestionAnswer
+{
+    protected $fillable = [
+        'question_id',
+        'customer_id',
+        'customer_name',
+        'customer_email',
+        'answer',
+        'status',
+        'is_pinned',
+        'useful_count',
+    ];
+
+    protected $casts = [
+        'id'            => 'int',
+        'question_id'   => 'int',
+        'customer_id'   => 'int',
+        'answer'        => 'string',
+        'customer_name' => 'string',
+        'status'        => 'string',
+        'is_pinned'     => 'bool',
+        'useful_count'  => 'int',
+        'created_at'    => 'datetime',
+        'updated_at'    => 'datetime',
+    ];
+
+    public function __get($key)
+    {
+        if ($this->hasAttribute($key)) {
+            return $this->getAttribute($key);
+        }
+
+        return parent::__get($key);
+    }
+
+    public function __isset($key)
+    {
+        if ($this->hasAttribute($key)) {
+            return true;
+        }
+
+        return parent::__isset($key);
+    }
+
+    public function __set($key, $value)
+    {
+        $own = ['id', 'question_id', 'customer_id', 'customer_name', 'customer_email', 'answer', 'status', 'is_pinned', 'useful_count', 'created_at', 'updated_at'];
+        if (in_array($key, $own)) {
+            $this->setAttribute($key, $value);
+        } else {
+            parent::__set($key, $value);
+        }
+    }
+
+    public function getId(): int
+    {
+        return (int) $this->getAttribute('id');
+    }
+}

+ 47 - 0
packages/Webkul/BagistoApi/src/Models/ProductQuestionAnswerVote.php

@@ -0,0 +1,47 @@
+<?php
+
+namespace Webkul\BagistoApi\Models;
+
+/**
+ * Thin API extension of the domain ProductQuestionAnswerVote model.
+ * Vote creation is handled via POST /api/shop/answers/{id}/vote
+ * on the ProductQuestionAnswer resource, so this class is only used
+ * as a domain-level proxy reference in BagistoApi context.
+ */
+class ProductQuestionAnswerVote extends \Webkul\Product\Models\ProductQuestionAnswerVote
+{
+    protected $fillable = [
+        'answer_id',
+        'customer_id',
+        'ip_address',
+    ];
+
+    protected $casts = [
+        'id'          => 'int',
+        'answer_id'   => 'int',
+        'customer_id' => 'int',
+    ];
+
+    public function __get($key)
+    {
+        if ($this->hasAttribute($key)) {
+            return $this->getAttribute($key);
+        }
+
+        return parent::__get($key);
+    }
+
+    public function __isset($key)
+    {
+        if ($this->hasAttribute($key)) {
+            return true;
+        }
+
+        return parent::__isset($key);
+    }
+
+    public function getId(): int
+    {
+        return (int) $this->getAttribute('id');
+    }
+}

+ 74 - 0
packages/Webkul/BagistoApi/src/Models/ProductSearch.php

@@ -0,0 +1,74 @@
+<?php
+
+namespace Webkul\BagistoApi\Models;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\GraphQl\Query;
+use Webkul\BagistoApi\Dto\ProductSearch\ProductSearchEdgeDto;
+use Webkul\BagistoApi\Dto\ProductSearch\ProductSearchPageInfoDto;
+use Webkul\BagistoApi\Dto\ProductSearch\SearchMetadataDto;
+use Webkul\BagistoApi\Resolver\ProductSearchResolver;
+
+#[ApiResource(
+    shortName: 'Search',
+    operations: [],
+    graphQlOperations: [
+        new Query(
+            resolver: ProductSearchResolver::class,
+            args: [
+                'query' => [
+                    'type'        => 'String!',
+                    'description' => 'Product search text.',
+                ],
+                'suggest' => [
+                    'type'        => 'Boolean',
+                    'description' => 'Enable typo correction, fuzzy matching, and query fallback.',
+                ],
+                'filter' => [
+                    'type'        => 'String',
+                    'description' => 'JSON product filter object.',
+                ],
+                'sortKey' => [
+                    'type'        => 'String',
+                    'description' => 'Sort by ID, TITLE, NAME, CREATED_AT, UPDATED_AT, or PRICE.',
+                ],
+                'reverse' => [
+                    'type'        => 'Boolean',
+                    'description' => 'Reverse the selected sort order.',
+                ],
+                'first'   => ['type' => 'Int'],
+                'last'    => ['type' => 'Int'],
+                'after'   => ['type' => 'String'],
+                'before'  => ['type' => 'String'],
+                'locale'  => ['type' => 'String'],
+                'channel' => ['type' => 'String'],
+            ],
+            read: false,
+            paginationEnabled: false,
+            description: 'Search products with inline correction metadata.',
+        ),
+    ],
+    normalizationContext: ['skip_null_values' => false],
+)]
+class ProductSearch
+{
+    public int $total_count = 0;
+
+    /**
+     * @var ProductSearchEdgeDto[]
+     */
+    #[ApiProperty(readableLink: true)]
+    public array $edges = [];
+
+    #[ApiProperty(readableLink: true)]
+    public ProductSearchPageInfoDto $page_info;
+
+    #[ApiProperty(readableLink: true)]
+    public ?SearchMetadataDto $search_metadata = null;
+
+    public function __construct()
+    {
+        $this->page_info = new ProductSearchPageInfoDto;
+    }
+}

+ 56 - 0
packages/Webkul/BagistoApi/src/Models/ReadCart.php

@@ -177,6 +177,62 @@ class ReadCart extends Cart
     #[Groups(['mutation'])]
     public ?string $formattedDiscountAmount = null;
 
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public ?string $giftcardNumber = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public ?float $giftcardAmount = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public ?float $baseGiftcardAmount = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public ?string $formattedGiftcardAmount = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public ?float $remainingGiftcardAmount = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public ?string $formattedRemainingGiftcardAmount = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public ?float $vipPlusAmount = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public ?float $baseVipPlusAmount = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public ?string $formattedVipPlusAmount = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public ?float $vipDiscountAmount = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public ?float $baseVipDiscountAmount = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public ?string $formattedVipDiscountAmount = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public ?bool $isVip = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public ?string $vipExpireDate = null;
+
 
     #[ApiProperty(readableLink: true, writable: false, readable: true)]
     public function customer(): \Illuminate\Database\Eloquent\Relations\BelongsTo

+ 34 - 1
packages/Webkul/BagistoApi/src/Providers/BagistoApiServiceProvider.php

@@ -27,6 +27,7 @@ use Webkul\BagistoApi\Resolver\CategoryCollectionResolver;
 use Webkul\BagistoApi\Resolver\CustomerQueryResolver;
 use Webkul\BagistoApi\Resolver\Factory\ProductRelationResolverFactory;
 use Webkul\BagistoApi\Resolver\ProductCollectionResolver;
+use Webkul\BagistoApi\Resolver\ProductSearchResolver;
 use Webkul\BagistoApi\Resolver\SingleProductBagistoApiResolver;
 use Webkul\BagistoApi\Resolver\PageByUrlKeyResolver;
 use Webkul\BagistoApi\Routing\CustomIriConverter;
@@ -39,6 +40,7 @@ use Webkul\BagistoApi\State\PaymentCallbackProcessor;
 use Webkul\BagistoApi\State\PaymentInitiateProcessor;
 use Webkul\BagistoApi\State\PaymentReplayProcessor;
 use Webkul\BagistoApi\State\SaveCheckoutCartProcessor;
+use Longyi\Member\Services\MemberDiscountService;
 use Webkul\BagistoApi\State\BookingSlotProvider;
 use Webkul\BagistoApi\State\PageProvider;
 use Webkul\BagistoApi\State\AttributeCollectionProvider;
@@ -195,6 +197,8 @@ class BagistoApiServiceProvider extends ServiceProvider
         $this->app->tag(ReorderProcessor::class, ProcessorInterface::class);
         $this->app->tag(ContactUsProcessor::class, ProcessorInterface::class);
         $this->app->tag(SaveCheckoutCartProcessor::class, ProcessorInterface::class);
+        $this->app->tag(\Webkul\BagistoApi\State\ProductQuestionProcessor::class, ProcessorInterface::class);
+        $this->app->tag(\Webkul\BagistoApi\State\ProductQuestionAnswerProcessor::class, ProcessorInterface::class);
 
         $this->app->tag(TokenHeaderDenormalizer::class, 'serializer.normalizer');
         $this->app->extend('api_platform_normalizer_list', function (\SplPriorityQueue $list, $app) {
@@ -253,6 +257,8 @@ class BagistoApiServiceProvider extends ServiceProvider
         $this->app->singleton(SaveCheckoutCartProcessor::class, function ($app) {
             return new SaveCheckoutCartProcessor(
                 $app->make('Webkul\CartRule\Repositories\CartRuleCouponRepository'),
+                $app->make(\Longyi\Gift\Services\GiftCardService::class),
+                $app->make(MemberDiscountService::class)
             );
         });
 
@@ -290,6 +296,18 @@ class BagistoApiServiceProvider extends ServiceProvider
             );
         });
 
+        $this->app->singleton(\Webkul\BagistoApi\State\ProductQuestionProcessor::class, function ($app) {
+            return new \Webkul\BagistoApi\State\ProductQuestionProcessor(
+                $app->make(PersistProcessor::class)
+            );
+        });
+
+        $this->app->singleton(\Webkul\BagistoApi\State\ProductQuestionAnswerProcessor::class, function ($app) {
+            return new \Webkul\BagistoApi\State\ProductQuestionAnswerProcessor(
+                $app->make(PersistProcessor::class)
+            );
+        });
+
         $this->app->singleton(CompareItemProcessor::class, function ($app) {
             return new CompareItemProcessor(
                 $app->make(PersistProcessor::class)
@@ -459,7 +477,9 @@ class BagistoApiServiceProvider extends ServiceProvider
 
         $this->app->singleton(ProductGraphQLProvider::class, function ($app) {
             return new ProductGraphQLProvider(
-                $app->make(Pagination::class)
+                $app->make(Pagination::class),
+                $app->make(\Webkul\Product\Repositories\ElasticSearchRepository::class),
+                $app->make(\Webkul\Marketing\Services\SearchLogService::class)
             );
         });
 
@@ -476,6 +496,18 @@ class BagistoApiServiceProvider extends ServiceProvider
             );
         });
 
+        $this->app->singleton(\Webkul\BagistoApi\State\ProductQuestionProvider::class, function ($app) {
+            return new \Webkul\BagistoApi\State\ProductQuestionProvider(
+                $app->make(Pagination::class)
+            );
+        });
+
+        $this->app->singleton(\Webkul\BagistoApi\State\ProductQuestionAnswerProvider::class, function ($app) {
+            return new \Webkul\BagistoApi\State\ProductQuestionAnswerProvider(
+                $app->make(Pagination::class)
+            );
+        });
+
         $this->app->singleton(GroupedProductsProvider::class, function ($app) {
             return new GroupedProductsProvider(
                 $app->make(Pagination::class)
@@ -526,6 +558,7 @@ class BagistoApiServiceProvider extends ServiceProvider
         $this->app->tag(\Webkul\BagistoApi\Resolver\GdprRequestQueryResolver::class, QueryItemResolverInterface::class);
         $this->app->tag(CustomerQueryResolver::class, QueryItemResolverInterface::class);
         $this->app->tag(PageByUrlKeyResolver::class, QueryCollectionResolverInterface::class);
+        $this->app->tag(ProductSearchResolver::class, QueryItemResolverInterface::class);
 
         $this->app->extend(ResolverFactoryInterface::class, function ($resolverFactory, $app) {
             return new ProductRelationResolverFactory(

+ 11 - 0
packages/Webkul/BagistoApi/src/Resolver/Factory/ProductRelationResolverFactory.php

@@ -8,6 +8,7 @@ use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
 use GraphQL\Type\Definition\ResolveInfo;
 use Webkul\BagistoApi\Dto\CartData;
 use Webkul\BagistoApi\Models\Product;
+use Webkul\BagistoApi\Models\ProductSearch;
 use Webkul\BagistoApi\Models\ReadCart;
 use Webkul\BagistoApi\State\ProductRelationProvider;
 
@@ -46,6 +47,16 @@ class ProductRelationResolverFactory implements ResolverFactoryInterface
 
         return function (?array $source, array $args, $context, ResolveInfo $info) use ($innerResolver, $capturedOperation, $resourceClass, $rootClass) {
 
+            if (
+                $rootClass === ProductSearch::class
+                && $info->fieldName === 'searchMetadata'
+                && is_array($source)
+                && array_key_exists('searchMetadata', $source)
+                && $source['searchMetadata'] === null
+            ) {
+                return null;
+            }
+
             // Handle CartData items field - fetch items fresh from database to avoid denormalization issues
             $isCartContext = in_array($resourceClass, [ReadCart::class, CartData::class], true)
                 || in_array($rootClass, [ReadCart::class, CartData::class], true);

+ 96 - 0
packages/Webkul/BagistoApi/src/Resolver/ProductSearchResolver.php

@@ -0,0 +1,96 @@
+<?php
+
+namespace Webkul\BagistoApi\Resolver;
+
+use ApiPlatform\GraphQl\Resolver\QueryItemResolverInterface;
+use ApiPlatform\State\Pagination\HasNextPagePaginatorInterface;
+use ApiPlatform\State\Pagination\PaginatorInterface;
+use Webkul\BagistoApi\Dto\ProductSearch\ProductSearchEdgeDto;
+use Webkul\BagistoApi\Dto\ProductSearch\ProductSearchPageInfoDto;
+use Webkul\BagistoApi\Dto\ProductSearch\SearchMetadataDto;
+use Webkul\BagistoApi\Models\ProductSearch;
+use Webkul\BagistoApi\State\ProductGraphQLProvider;
+
+class ProductSearchResolver implements QueryItemResolverInterface
+{
+    public function __construct(
+        private readonly ProductGraphQLProvider $productProvider
+    ) {}
+
+    public function __invoke(?object $item, array $context): object
+    {
+        $args = $context['args'] ?? [];
+        $paginator = $this->productProvider->provide(
+            $context['operation'],
+            [],
+            ['args' => $args]
+        );
+
+        if (! $paginator instanceof PaginatorInterface) {
+            throw new \UnexpectedValueException('Product search provider must return a paginator.');
+        }
+
+        $result = new ProductSearch;
+        $result->total_count = (int) $paginator->getTotalItems();
+
+        $offset = $this->resolveOffset($args, $result->total_count);
+
+        foreach ($paginator as $index => $product) {
+            $edge = new ProductSearchEdgeDto;
+            $edge->cursor = base64_encode((string) ($offset + $index));
+            $edge->node = $product;
+            $result->edges[] = $edge;
+        }
+
+        $result->page_info = $this->buildPageInfo($result->edges, $offset, $paginator);
+
+        $metadata = request()->attributes->get('bagistoapi.search_metadata');
+
+        if (is_array($metadata)) {
+            $result->search_metadata = SearchMetadataDto::fromArray($metadata);
+        }
+
+        request()->attributes->remove('bagistoapi.search_metadata');
+
+        return $result;
+    }
+
+    private function resolveOffset(array $args, int $total): int
+    {
+        $limit = max(1, (int) ($args['first'] ?? $args['last'] ?? 30));
+        $offset = 0;
+
+        if (! empty($args['after'])) {
+            $decoded = base64_decode($args['after'], true);
+            $offset = ctype_digit((string) $decoded) ? ((int) $decoded + 1) : 0;
+        }
+
+        if (! empty($args['before'])) {
+            $decoded = base64_decode($args['before'], true);
+            $cursor = ctype_digit((string) $decoded) ? (int) $decoded : 0;
+            $offset = max(0, $cursor - $limit);
+        }
+
+        if ($offset > $total) {
+            $offset = max(0, $total - $limit);
+        }
+
+        return $offset;
+    }
+
+    private function buildPageInfo(
+        array $edges,
+        int $offset,
+        PaginatorInterface $paginator
+    ): ProductSearchPageInfoDto {
+        $pageInfo = new ProductSearchPageInfoDto;
+        $pageInfo->hasPreviousPage = $offset > 0;
+        $pageInfo->hasNextPage = $paginator instanceof HasNextPagePaginatorInterface
+            ? $paginator->hasNextPage()
+            : $paginator->getCurrentPage() < $paginator->getLastPage();
+        $pageInfo->startCursor = $edges[0]->cursor ?? null;
+        $pageInfo->endCursor = $edges[array_key_last($edges)]->cursor ?? null;
+
+        return $pageInfo;
+    }
+}

+ 28 - 0
packages/Webkul/BagistoApi/src/Resources/config/serializer/CartData.yaml

@@ -61,6 +61,34 @@ Webkul\BagistoApi\Dto\CartData:
       type: string
     couponCode:
       type: string
+    giftcardNumber:
+      type: string
+    giftcardAmount:
+      type: float
+    baseGiftcardAmount:
+      type: float
+    formattedGiftcardAmount:
+      type: string
+    remainingGiftcardAmount:
+      type: float
+    formattedRemainingGiftcardAmount:
+      type: string
+    vipPlusAmount:
+      type: float
+    baseVipPlusAmount:
+      type: float
+    formattedVipPlusAmount:
+      type: string
+    vipDiscountAmount:
+      type: float
+    baseVipDiscountAmount:
+      type: float
+    formattedVipDiscountAmount:
+      type: string
+    isVip:
+      type: bool
+    vipExpireDate:
+      type: string
     isGuest:
       type: bool
     itemsQty:

+ 147 - 19
packages/Webkul/BagistoApi/src/Services/PaymentService.php

@@ -479,7 +479,7 @@ class PaymentService
             return (string) $gatewayOrderId;
         }
     }
-    protected function resolveGatewayHandler(string $method, $order = null, $input = null): ?object
+    public function resolveGatewayHandler(string $method, $order = null, $input = null): ?object
     {
         $config = config("payment_methods.{$method}");
 
@@ -577,6 +577,116 @@ class PaymentService
         }
     }
 
+    /**
+     * Settle a capture reported by a verified PayPal webhook.
+     *
+     * Unlike callback(), this path must never call captureOrder() again:
+     * PAYMENT.CAPTURE.COMPLETED means PayPal has already moved the money.
+     */
+    public function completeWebhookCapture(
+        $order,
+        string $gatewayOrderId,
+        array $capture,
+        string $eventId,
+    ): array {
+        if (! $this->isPending($order)) {
+            return $this->successResponse($order, 'already_processed');
+        }
+
+        $method = $order->payment?->method;
+        $isExpress = $this->expressFlag($order);
+
+        try {
+            $result = DB::transaction(function () use ($order, $gatewayOrderId, $capture, $method, $isExpress) {
+                $orderModelClass = OrderProxy::modelClass();
+                $orderModelClass::query()->whereKey($order->id)->lockForUpdate()->first();
+
+                $order->refresh();
+
+                if (! $this->isPending($order)) {
+                    return [
+                        'skipped'  => true,
+                        'capture'  => null,
+                        'response' => $this->successResponse($order, 'already_processed'),
+                    ];
+                }
+
+                if ($method !== 'paypal_smart_button' || $order->payment?->method !== 'paypal_smart_button') {
+                    throw new OperationFailedException('The order does not use PayPal Smart Button.');
+                }
+
+                $storedGatewayOrderId = $this->gatewayOrderIdFromOrder($order);
+
+                if (
+                    ! $storedGatewayOrderId
+                    || ! hash_equals((string) $storedGatewayOrderId, $gatewayOrderId)
+                    || ! hash_equals((string) ($capture['gateway_order_id'] ?? ''), $gatewayOrderId)
+                ) {
+                    throw new OperationFailedException('The PayPal order id does not match the local order.');
+                }
+
+                if (
+                    empty($capture['transaction_id'])
+                    || strtoupper((string) ($capture['capture_status'] ?? '')) !== 'COMPLETED'
+                    || ! array_key_exists('amount', $capture)
+                    || $capture['amount'] === null
+                    || empty($capture['currency'])
+                ) {
+                    throw new OperationFailedException('The PayPal capture is incomplete or not completed.');
+                }
+
+                $this->assertAmountMatches($order, $capture);
+
+                if ($isExpress && $this->isPlaceholderAddress($order->shipping_address)) {
+                    Log::warning('PayPal webhook settled an express order with a placeholder address', [
+                        'order_id'         => $order->id,
+                        'gateway_order_id' => $gatewayOrderId,
+                    ]);
+                }
+
+                return $this->finalizeCapturedOrder($order, $capture);
+            });
+        } catch (OperationFailedException $e) {
+            $this->recordAttempt([
+                'order_id'         => $order->id,
+                'cart_id'          => $this->resolveOldCartId($order),
+                'payment_method'   => $method,
+                'gateway_order_id' => $gatewayOrderId,
+                'action'           => PaymentAttempt::ACTION_WEBHOOK,
+                'status'           => PaymentAttempt::STATUS_FAILED,
+                'amount'           => (float) ($order->grand_total ?? 0),
+                'currency'         => $order->order_currency_code ?? $order->cart_currency_code,
+                'express'          => $isExpress,
+                'response_payload' => [
+                    'event_id' => $eventId,
+                    'error'    => $e->getMessage(),
+                ],
+            ]);
+
+            throw $e;
+        }
+
+        if (empty($result['skipped'])) {
+            $this->recordAttempt([
+                'order_id'         => $order->id,
+                'cart_id'          => $this->resolveOldCartId($order),
+                'payment_method'   => $method,
+                'gateway_order_id' => $gatewayOrderId,
+                'action'           => PaymentAttempt::ACTION_WEBHOOK,
+                'status'           => PaymentAttempt::STATUS_CAPTURED,
+                'amount'           => (float) ($order->grand_total ?? 0),
+                'currency'         => $order->order_currency_code ?? $order->cart_currency_code,
+                'express'          => $isExpress,
+                'idempotency_key'  => 'paypal-webhook:'.$eventId,
+                'response_payload' => $result['capture'] ?? null,
+            ]);
+
+            Event::dispatch('bagistoapi.payment.success', $result['response']['order']);
+        }
+
+        return $result['response'];
+    }
+
     /**
      * Success branch: capture the gateway order, verify the captured
      * amount, fill in express addresses, flip the order to processing
@@ -626,25 +736,8 @@ class PaymentService
                         $capture = $this->captureAndVerify($order, $gatewayOrderId);
                     }
                 }
-                if ($isExpress) {
-                    $this->fillAddressesFromCallback($order, $input);
-                }
-
-                if ($capture && ! empty($capture['transaction_id'])) {
-                    $this->writeTransactionId($order, (string) $capture['transaction_id']);
-                }
-
-                $this->orderRepository->updateOrderStatus($order, Order::STATUS_PROCESSING);
-
-                $invoice = $this->createInvoiceIfPossible($order);
-
-                if ($capture) {
-                    $this->recordOrderTransaction($order, $invoice, $capture);
-                }
 
-                $order->refresh();
-
-                return ['skipped' => false, 'capture' => $capture, 'response' => $this->successResponse($order, 'captured')];
+                return $this->finalizeCapturedOrder($order, $capture, $isExpress ? $input : null);
             });
         } catch (OperationFailedException $e) {
             /*
@@ -688,6 +781,41 @@ class PaymentService
         return $result['response'];
     }
 
+    /**
+     * Apply the local side effects for an already completed capture.
+     *
+     * Callers must hold the order row lock before invoking this method.
+     */
+    protected function finalizeCapturedOrder(
+        $order,
+        ?array $capture,
+        ?PaymentCallbackInput $input = null,
+    ): array {
+        if ($input) {
+            $this->fillAddressesFromCallback($order, $input);
+        }
+
+        if ($capture && ! empty($capture['transaction_id'])) {
+            $this->writeTransactionId($order, (string) $capture['transaction_id']);
+        }
+
+        $this->orderRepository->updateOrderStatus($order, Order::STATUS_PROCESSING);
+
+        $invoice = $this->createInvoiceIfPossible($order);
+
+        if ($capture) {
+            $this->recordOrderTransaction($order, $invoice, $capture);
+        }
+
+        $order->refresh();
+
+        return [
+            'skipped'  => false,
+            'capture'  => $capture,
+            'response' => $this->successResponse($order, 'captured'),
+        ];
+    }
+
     /**
      * Cancel/failure branch: keep the order in PENDING and let the
      * caller decide whether to actually cancel it via the dedicated

+ 43 - 45
packages/Webkul/BagistoApi/src/State/CartTokenProcessor.php

@@ -1027,64 +1027,62 @@ class CartTokenProcessor implements ProcessorInterface
 
         $guestCart->load('items.child');
 
-        foreach ($guestCart->items as $item) {
-            try {
-                $cartItem = $customerCart->items()
-                    ->where('product_id', $item->product_id)
-                    ->where('type', $item->type)
-                    ->first();
-
-                if ($cartItem) {
-                    $cartItem->update([
-                        'quantity' => $cartItem->quantity + $item->quantity,
+        $guard = auth()->guard();
+        $previousCustomer = $guard->user();
+
+        $guard->setUser($customer);
+        CartFacade::setCart($customerCart);
+
+        try {
+            foreach ($guestCart->items as $item) {
+                if (! $item->product) {
+                    continue;
+                }
+
+                try {
+                    CartFacade::addProduct($item->product, [
+                        ...(is_array($item->additional) ? $item->additional : []),
+                        'quantity' => $item->quantity,
                     ]);
-                } else {
-                    $newItem = $item->replicate()
-                        ->fill(['cart_id' => $customerCart->id]);
-                    $newItem->save();
-
-                    // Replicate child item for configurable products
-                    if ($item->type === 'configurable' && $item->child) {
-                        $item->child->replicate()
-                            ->fill([
-                                'cart_id'   => $customerCart->id,
-                                'parent_id' => $newItem->id,
-                            ])
-                            ->save();
-                    }
+                } catch (\Exception $e) {
+                    continue;
                 }
-            } catch (\Exception $e) {
-                continue;
             }
-        }
 
-        $guestCart->update(['is_active' => 0]);
+            $guestCart->update(['is_active' => 0]);
 
-        // Reload cart with relationships and remove invalid items
-        // (e.g., configurable items without child entries or deleted products)
-        $customerCart = CartModel::with('items.product', 'items.child.product')->find($customerCart->id);
+            // Reload cart with relationships and remove invalid items
+            // (e.g., configurable items without child entries or deleted products)
+            $customerCart = CartModel::with('items.product', 'items.child.product')->find($customerCart->id);
 
-        foreach ($customerCart->items as $item) {
-            if (! $item->product
-                || ($item->type === 'configurable' && ! $item->child)
-            ) {
-                $item->delete();
+            foreach ($customerCart->items as $item) {
+                if (! $item->product
+                    || ($item->type === 'configurable' && ! $item->child)
+                ) {
+                    $item->delete();
+                }
             }
-        }
 
-        $customerCart = CartModel::with('items.product')->find($customerCart->id);
+            $customerCart = CartModel::with('items.product')->find($customerCart->id);
 
-        CartFacade::setCart($customerCart);
+            CartFacade::setCart($customerCart);
 
-        CartFacade::collectTotals();
+            CartFacade::collectTotals();
 
-        $customerCart = CartModel::find($customerCart->id);
+            $customerCart = CartModel::find($customerCart->id);
 
-        $cartData = CartData::fromModel($customerCart);
-        $cartData->success = true;
-        $cartData->message = __('bagistoapi::app.graphql.cart.guest-cart-merged');
+            $cartData = CartData::fromModel($customerCart);
+            $cartData->success = true;
+            $cartData->message = __('bagistoapi::app.graphql.cart.guest-cart-merged');
 
-        return (array) $cartData;
+            return (array) $cartData;
+        } finally {
+            if ($previousCustomer) {
+                $guard->setUser($previousCustomer);
+            } else {
+                app('auth')->forgetGuards();
+            }
+        }
     }
 
     /**

+ 2 - 0
packages/Webkul/BagistoApi/src/State/CheckoutAddressProvider.php

@@ -67,6 +67,7 @@ class CheckoutAddressProvider implements ProviderInterface
             $output->billingCity = $billingAddress->city;
             $output->billingPostcode = $billingAddress->postcode;
             $output->billingPhoneNumber = $billingAddress->phone;
+            $output->billingAddressId = $billingAddress->id;
         }
 
         $shippingAddress = $cart->shipping_address;
@@ -81,6 +82,7 @@ class CheckoutAddressProvider implements ProviderInterface
             $output->shippingCity = $shippingAddress->city;
             $output->shippingPostcode = $shippingAddress->postcode;
             $output->shippingPhoneNumber = $shippingAddress->phone;
+            $output->shippingAddressId = $shippingAddress->id;
         }
 
         $output->success = true;

+ 4 - 0
packages/Webkul/BagistoApi/src/State/CheckoutProcessor.php

@@ -522,6 +522,7 @@ class CheckoutProcessor implements ProcessorInterface
             $output->billingCity = (string) ($billingAddress->city ?? '');
             $output->billingPostcode = (string) ($billingAddress->postcode ?? '');
             $output->billingPhoneNumber = (string) ($billingAddress->phone ?? '');
+            $output->billingAddressId = $billingAddress->id;
         }
 
         if ($shippingAddress) {
@@ -535,6 +536,7 @@ class CheckoutProcessor implements ProcessorInterface
             $output->shippingCity = (string) ($shippingAddress->city ?? '');
             $output->shippingPostcode = (string) ($shippingAddress->postcode ?? '');
             $output->shippingPhoneNumber = (string) ($shippingAddress->phone ?? '');
+            $output->shippingAddressId = $shippingAddress->id;
         }
 
         return $output;
@@ -564,6 +566,7 @@ class CheckoutProcessor implements ProcessorInterface
                 $output->billingCity = $billingAddress->city;
                 $output->billingPostcode = $billingAddress->postcode;
                 $output->billingPhoneNumber = $billingAddress->phone;
+                $output->billingId = $billingAddress->id;
             }
 
             $shippingAddress = $cart->shipping_address;
@@ -578,6 +581,7 @@ class CheckoutProcessor implements ProcessorInterface
                 $output->shippingCity = $shippingAddress->city;
                 $output->shippingPostcode = $shippingAddress->postcode;
                 $output->shippingPhoneNumber = $shippingAddress->phone;
+                $output->shippingId = $shippingAddress->id;
             }
 
             $output->success = true;

+ 100 - 60
packages/Webkul/BagistoApi/src/State/ProductGraphQLProvider.php

@@ -8,11 +8,15 @@ use ApiPlatform\State\Pagination\Pagination;
 use ApiPlatform\State\ProviderInterface;
 use Illuminate\Pagination\LengthAwarePaginator as LaravelPaginator;
 use Webkul\BagistoApi\Models\Product;
+use Webkul\Marketing\Services\SearchLogService;
+use Webkul\Product\Repositories\ElasticSearchRepository;
 
 class ProductGraphQLProvider implements ProviderInterface
 {
     public function __construct(
-        private readonly Pagination $pagination
+        private readonly Pagination $pagination,
+        private readonly ElasticSearchRepository $elasticSearchRepository,
+        private readonly SearchLogService $searchLogService
     ) {}
 
     private ?array $attributeTypeCache = null;
@@ -33,6 +37,8 @@ class ProductGraphQLProvider implements ProviderInterface
 
         $args = $context['args'] ?? [];
 
+        request()->attributes->remove('bagistoapi.search_metadata');
+
         $query = Product::query();
 
         $query->whereHas('attribute_values', function ($q) {
@@ -45,7 +51,30 @@ class ProductGraphQLProvider implements ProviderInterface
                 ->where('boolean_value', 1);
         });
 
-        if (! empty($args['query'])) {
+        $elasticSearchIds = null;
+        $suggest = filter_var($args['suggest'] ?? false, FILTER_VALIDATE_BOOLEAN);
+        $effectiveQuery = $args['query'] ?? '';
+
+        if (
+            ! empty($args['query'])
+            && core()->getConfigData('catalog.products.search.engine') === 'elastic'
+            && core()->getConfigData('catalog.products.search.storefront_mode') === 'elastic'
+        ) {
+            $searchResult = $this->elasticSearchRepository->searchForApi($args['query'], $suggest);
+            $elasticSearchIds = $searchResult['ids'];
+            $effectiveQuery = $searchResult['query'];
+
+            if ($searchResult['query'] !== $args['query']) {
+                request()->attributes->set('bagistoapi.search_metadata', [
+                    'originalQuery'       => $args['query'],
+                    'effectiveQuery'      => $searchResult['query'],
+                    'resultMessage'       => 'These are results for: '.$searchResult['query'],
+                    'searchInsteadMessage' => 'Search instead for '.$args['query'],
+                ]);
+            }
+
+            $query->whereIn('products.id', $elasticSearchIds);
+        } elseif (! empty($args['query'])) {
             $searchTerm = $args['query'];
 
             $query->where(function ($q) use ($searchTerm) {
@@ -63,66 +92,70 @@ class ProductGraphQLProvider implements ProviderInterface
         $locale = $args['locale'] ?? request()->attributes->get('bagisto_locale');
         $channel = $args['channel'] ?? request()->attributes->get('bagisto_channel');
 
-        switch ($sortKey) {
-            case 'TITLE':
-            case 'NAME':
-                $prefix = \DB::getTablePrefix();
-
-                // Join for requested locale/channel
-                $query->leftJoin('product_attribute_values as pav_name_locale', function ($join) use ($locale, $channel) {
-                    $join->on('products.id', '=', 'pav_name_locale.product_id')
-                        ->where('pav_name_locale.attribute_id', 2);
-
-                    if ($locale) {
-                        $join->where('pav_name_locale.locale', $locale);
-                    }
-
-                    if ($channel) {
-                        $join->where('pav_name_locale.channel', $channel);
-                    }
-                });
+        if (! empty($elasticSearchIds) && ! isset($args['sortKey'])) {
+            $query->orderByRaw('FIELD(products.id, '.implode(',', $elasticSearchIds).')');
+        } else {
+            switch ($sortKey) {
+                case 'TITLE':
+                case 'NAME':
+                    $prefix = \DB::getTablePrefix();
+
+                    // Join for requested locale/channel
+                    $query->leftJoin('product_attribute_values as pav_name_locale', function ($join) use ($locale, $channel) {
+                        $join->on('products.id', '=', 'pav_name_locale.product_id')
+                            ->where('pav_name_locale.attribute_id', 2);
+
+                        if ($locale) {
+                            $join->where('pav_name_locale.locale', $locale);
+                        }
+
+                        if ($channel) {
+                            $join->where('pav_name_locale.channel', $channel);
+                        }
+                    });
 
-                // Fallback join for null locale/channel (default values)
-                $query->leftJoin('product_attribute_values as pav_name_fallback', function ($join) {
-                    $join->on('products.id', '=', 'pav_name_fallback.product_id')
-                        ->where('pav_name_fallback.attribute_id', 2)
-                        ->whereNull('pav_name_fallback.locale')
-                        ->whereNull('pav_name_fallback.channel');
-                });
+                    // Fallback join for null locale/channel (default values)
+                    $query->leftJoin('product_attribute_values as pav_name_fallback', function ($join) {
+                        $join->on('products.id', '=', 'pav_name_fallback.product_id')
+                            ->where('pav_name_fallback.attribute_id', 2)
+                            ->whereNull('pav_name_fallback.locale')
+                            ->whereNull('pav_name_fallback.channel');
+                    });
 
-                $query->orderBy(\DB::raw("COALESCE({$prefix}pav_name_locale.text_value, {$prefix}pav_name_fallback.text_value)"), $direction)
-                    ->orderBy('products.id', $direction)
-                    ->select('products.*');
-                break;
-
-            case 'CREATED_AT':
-                $query->orderBy('products.created_at', $direction)
-                    ->orderBy('products.id', $direction);
-                break;
-
-            case 'UPDATED_AT':
-                $query->orderBy('products.updated_at', $direction)
-                    ->orderBy('products.id', $direction);
-                break;
-
-            case 'PRICE':
-                $user = auth()->user();
-                $customerGroup = $user?->getDefaultGroup()
-                    ?? app(\Webkul\Customer\Repositories\CustomerGroupRepository::class)
-                        ->findOneByField('code', 'guest');
-
-                $query->leftJoin('product_price_indices', function ($join) use ($customerGroup) {
-                    $join->on('products.id', '=', 'product_price_indices.product_id')
-                        ->where('product_price_indices.customer_group_id', $customerGroup->id);
-                })
-                    ->orderBy('product_price_indices.min_price', $direction)
-                    ->orderBy('products.id', $direction)
-                    ->select('products.*');
-                break;
-
-            case 'ID':
-            default:
-                $query->orderBy('products.id', $direction);
+                    $query->orderBy(\DB::raw("COALESCE({$prefix}pav_name_locale.text_value, {$prefix}pav_name_fallback.text_value)"), $direction)
+                        ->orderBy('products.id', $direction)
+                        ->select('products.*');
+                    break;
+
+                case 'CREATED_AT':
+                    $query->orderBy('products.created_at', $direction)
+                        ->orderBy('products.id', $direction);
+                    break;
+
+                case 'UPDATED_AT':
+                    $query->orderBy('products.updated_at', $direction)
+                        ->orderBy('products.id', $direction);
+                    break;
+
+                case 'PRICE':
+                    $user = auth()->user();
+                    $customerGroup = $user?->getDefaultGroup()
+                        ?? app(\Webkul\Customer\Repositories\CustomerGroupRepository::class)
+                            ->findOneByField('code', 'guest');
+
+                    $query->leftJoin('product_price_indices', function ($join) use ($customerGroup) {
+                        $join->on('products.id', '=', 'product_price_indices.product_id')
+                            ->where('product_price_indices.customer_group_id', $customerGroup->id);
+                    })
+                        ->orderBy('product_price_indices.min_price', $direction)
+                        ->orderBy('products.id', $direction)
+                        ->select('products.*');
+                    break;
+
+                case 'ID':
+                default:
+                    $query->orderBy('products.id', $direction);
+            }
         }
 
         $filters = [];
@@ -300,6 +333,13 @@ class ProductGraphQLProvider implements ProviderInterface
             ->distinct('products.id')
             ->count('products.id');
 
+        $this->searchLogService->recordWhenSuggested(
+            $args['suggest'] ?? false,
+            $args['query'] ?? '',
+            $effectiveQuery,
+            $total
+        );
+
         if ($offset > $total) {
             $offset = max(0, $total - $limit);
         }

+ 227 - 0
packages/Webkul/BagistoApi/src/State/ProductQuestionAnswerProcessor.php

@@ -0,0 +1,227 @@
+<?php
+
+namespace Webkul\BagistoApi\State;
+
+use ApiPlatform\Metadata\Delete;
+use ApiPlatform\Metadata\Operation;
+use ApiPlatform\Metadata\Patch;
+use ApiPlatform\Metadata\Post;
+use ApiPlatform\State\ProcessorInterface;
+use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\DB;
+use Webkul\BagistoApi\Dto\CreateProductQuestionAnswerInput;
+use Webkul\BagistoApi\Exception\AuthenticationException;
+use Webkul\BagistoApi\Exception\InvalidInputException;
+use Webkul\BagistoApi\Exception\ResourceNotFoundException;
+use Webkul\BagistoApi\Models\ProductQuestion;
+use Webkul\BagistoApi\Models\ProductQuestionAnswer;
+use Webkul\BagistoApi\Models\ProductQuestionAnswerVote;
+
+class ProductQuestionAnswerProcessor implements ProcessorInterface
+{
+    public function __construct(
+        private readonly ProcessorInterface $persistProcessor
+    ) {}
+
+    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
+    {
+        if ($data instanceof CreateProductQuestionAnswerInput) {
+            return $this->handleCreate($data);
+        }
+
+        if ($operation instanceof Patch) {
+            return $this->handleUpdate($data, $uriVariables);
+        }
+
+        if ($operation instanceof Delete) {
+            return $this->handleDelete($data, $uriVariables);
+        }
+
+        if ($operation instanceof Post) {
+            // Check for the vote sub-operation via route name hint in context
+            $request = $context['request'] ?? request();
+            if (str_ends_with((string) $request->route()?->getName(), '.vote') || str_contains($request->path(), '/vote')) {
+                return $this->handleVote($uriVariables, $request);
+            }
+
+            return $this->handleRestCreate($context, $uriVariables);
+        }
+
+        return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
+    }
+
+    private function handleCreate(CreateProductQuestionAnswerInput $data): ProductQuestionAnswer
+    {
+        $question = ProductQuestion::find($data->questionId);
+        if (! $question || $question->status !== 'approved') {
+            throw new ResourceNotFoundException('Question not found or not approved.');
+        }
+
+        if (empty(trim($data->answer))) {
+            throw new InvalidInputException('Answer text is required.');
+        }
+
+        $answer = $this->createAnswer(
+            $data->questionId,
+            $data->answer,
+            $data->customerName,
+            $data->customerEmail
+        );
+
+        return $answer;
+    }
+
+    private function handleRestCreate(array $context, array $uriVariables): ProductQuestionAnswer
+    {
+        $request = $context['request'] ?? request();
+        $allData = method_exists($request, 'all') ? $request->all() : [];
+
+        // Support both nested route (/questions/{questionId}/answers) and flat body
+        $questionId = $uriVariables['questionId'] ?? $allData['question_id'] ?? null;
+        if (! $questionId) {
+            throw new InvalidInputException('question_id is required.');
+        }
+
+        $question = ProductQuestion::find((int) $questionId);
+        if (! $question || $question->status !== 'approved') {
+            throw new ResourceNotFoundException('Question not found or not approved.');
+        }
+
+        $answerText = $allData['answer'] ?? '';
+        if (empty(trim($answerText))) {
+            throw new InvalidInputException('Answer text is required.');
+        }
+
+        $customer = Auth::guard('sanctum')->user();
+        $customerName = $allData['customer_name'] ?? ($customer ? $customer->name : '');
+        if (empty(trim($customerName))) {
+            throw new InvalidInputException('customer_name is required.');
+        }
+
+        return $this->createAnswer(
+            (int) $questionId,
+            $answerText,
+            $customerName,
+            $allData['customer_email'] ?? ($customer ? $customer->email : null)
+        );
+    }
+
+    private function createAnswer(int $questionId, string $answerText, string $customerName, ?string $customerEmail): ProductQuestionAnswer
+    {
+        $customer = Auth::guard('sanctum')->user();
+
+        $answer = new ProductQuestionAnswer;
+        $answer->setAttribute('question_id', $questionId);
+        $answer->setAttribute('answer', $answerText);
+        $answer->setAttribute('customer_name', $customerName);
+        $answer->setAttribute('customer_email', $customerEmail);
+        $answer->setAttribute('status', 'pending');
+        $answer->setAttribute('is_pinned', false);
+        $answer->setAttribute('useful_count', 0);
+
+        if ($customer) {
+            $answer->setAttribute('customer_id', $customer->id);
+        }
+
+        $answer->save();
+
+        // Increment the cached answers_count on the parent question
+        DB::table('product_questions')
+            ->where('id', $questionId)
+            ->increment('answers_count');
+
+        return $answer;
+    }
+
+    /**
+     * Admin update: status (approve/reject), is_pinned
+     */
+    private function handleUpdate(mixed $data, array $uriVariables): ProductQuestionAnswer
+    {
+        $id = $uriVariables['id'] ?? (is_object($data) && isset($data->id) ? $data->id : null);
+        $answer = ProductQuestionAnswer::find((int) $id);
+
+        if (! $answer) {
+            throw new ResourceNotFoundException('Answer not found.');
+        }
+
+        $allData = request()->all();
+
+        if (isset($allData['status']) && in_array($allData['status'], ['pending', 'approved', 'rejected'], true)) {
+            $answer->setAttribute('status', $allData['status']);
+        }
+
+        if (isset($allData['is_pinned'])) {
+            $isPinned = filter_var($allData['is_pinned'], FILTER_VALIDATE_BOOLEAN);
+            // Unpin all other answers for this question before pinning this one
+            if ($isPinned) {
+                ProductQuestionAnswer::where('question_id', $answer->question_id)
+                    ->where('id', '!=', $answer->id)
+                    ->update(['is_pinned' => false]);
+            }
+            $answer->setAttribute('is_pinned', $isPinned);
+        }
+
+        $answer->save();
+
+        return $answer;
+    }
+
+    private function handleDelete(mixed $data, array $uriVariables): null
+    {
+        $id = $uriVariables['id'] ?? (is_object($data) ? $data->id : null);
+        $answer = ProductQuestionAnswer::find((int) $id);
+
+        if (! $answer) {
+            throw new ResourceNotFoundException('Answer not found.');
+        }
+
+        $questionId = $answer->question_id;
+        $answer->delete();
+
+        // Decrement answers_count (guard against going below 0)
+        DB::table('product_questions')
+            ->where('id', $questionId)
+            ->where('answers_count', '>', 0)
+            ->decrement('answers_count');
+
+        return null;
+    }
+
+    /**
+     * POST /api/shop/answers/{id}/vote
+     * Only authenticated customers may vote. One vote per customer.
+     */
+    private function handleVote(array $uriVariables, mixed $request): array
+    {
+        $customer = Auth::guard('sanctum')->user();
+        if (! $customer) {
+            throw new AuthenticationException('You must be logged in to vote.');
+        }
+
+        $id = $uriVariables['id'] ?? null;
+        $answer = ProductQuestionAnswer::find((int) $id);
+
+        if (! $answer || $answer->status !== 'approved') {
+            throw new ResourceNotFoundException('Answer not found or not approved.');
+        }
+
+        $exists = ProductQuestionAnswerVote::where('answer_id', $answer->id)
+            ->where('customer_id', $customer->id)
+            ->exists();
+
+        if ($exists) {
+            return ['voted' => false, 'useful_count' => $answer->useful_count, 'message' => 'Already voted.'];
+        }
+
+        $vote = new ProductQuestionAnswerVote;
+        $vote->setAttribute('answer_id', $answer->id);
+        $vote->setAttribute('customer_id', $customer->id);
+        $vote->save();
+
+        $answer->increment('useful_count');
+        $answer->refresh();
+
+        return ['voted' => true, 'useful_count' => $answer->useful_count, 'message' => 'Thank you for your feedback.'];
+    }
+}

+ 87 - 0
packages/Webkul/BagistoApi/src/State/ProductQuestionAnswerProvider.php

@@ -0,0 +1,87 @@
+<?php
+
+namespace Webkul\BagistoApi\State;
+
+use ApiPlatform\Laravel\Eloquent\Paginator;
+use ApiPlatform\Metadata\Operation;
+use ApiPlatform\State\Pagination\Pagination;
+use ApiPlatform\State\ProviderInterface;
+use Illuminate\Pagination\LengthAwarePaginator;
+use Webkul\BagistoApi\Models\ProductQuestionAnswer;
+
+class ProductQuestionAnswerProvider implements ProviderInterface
+{
+    public function __construct(
+        private readonly Pagination $pagination
+    ) {}
+
+    public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
+    {
+        $args = $context['args'] ?? [];
+        $request = request();
+
+        $query = ProductQuestionAnswer::query();
+
+        // Filter by question_id from URI variables (nested route) or query param
+        $questionId = $uriVariables['questionId'] ?? $args['question_id'] ?? $request->query('question_id');
+        if ($questionId) {
+            $query->where('question_id', (int) $questionId);
+        }
+
+        // Storefront shows approved answers by default; pinned answers first, then by useful_count
+        $status = $args['status'] ?? $request->query('status', 'approved');
+        $query->where('status', $status);
+
+        $query->with(['customer']);
+
+        // Pinned answers first, then by useful_count descending, then newest first
+        $query->orderByDesc('is_pinned')
+            ->orderByDesc('useful_count')
+            ->orderByDesc('created_at');
+
+        // REST pagination
+        $page = max(1, (int) ($request->query('page', 1)));
+        $perPage = min(50, max(1, (int) ($request->query('per_page', $request->query('limit', 30)))));
+
+        // GraphQL cursor pagination
+        $first = isset($args['first']) ? (int) $args['first'] : null;
+        $last = isset($args['last']) ? (int) $args['last'] : null;
+        $after = $args['after'] ?? null;
+        $before = $args['before'] ?? null;
+
+        if ($first || $last || $after || $before) {
+            $perPage = $first ?? $last ?? 30;
+            $offset = 0;
+
+            if ($after) {
+                $decoded = base64_decode($after, true);
+                $offset = ctype_digit((string) $decoded) ? ((int) $decoded + 1) : 0;
+            }
+
+            if ($before) {
+                $decoded = base64_decode($before, true);
+                $cursor = ctype_digit((string) $decoded) ? (int) $decoded : 0;
+                $offset = max(0, $cursor - $perPage);
+            }
+
+            $total = (clone $query)->count();
+            $items = $query->offset($offset)->limit($perPage)->get();
+            $currentPage = $total > 0 ? (int) floor($offset / $perPage) + 1 : 1;
+        } else {
+            $total = (clone $query)->count();
+            $offset = ($page - 1) * $perPage;
+            $items = $query->offset($offset)->limit($perPage)->get();
+            $currentPage = $page;
+        }
+
+        return new Paginator(
+            new LengthAwarePaginator(
+                $items,
+                $total,
+                $perPage,
+                $currentPage,
+                ['path' => request()->url()]
+            )
+        );
+    }
+}

+ 151 - 0
packages/Webkul/BagistoApi/src/State/ProductQuestionProcessor.php

@@ -0,0 +1,151 @@
+<?php
+
+namespace Webkul\BagistoApi\State;
+
+use ApiPlatform\Metadata\Delete;
+use ApiPlatform\Metadata\Operation;
+use ApiPlatform\Metadata\Patch;
+use ApiPlatform\Metadata\Post;
+use ApiPlatform\State\ProcessorInterface;
+use Illuminate\Support\Facades\Auth;
+use Webkul\BagistoApi\Dto\CreateProductQuestionInput;
+use Webkul\BagistoApi\Exception\AuthenticationException;
+use Webkul\BagistoApi\Exception\InvalidInputException;
+use Webkul\BagistoApi\Exception\ResourceNotFoundException;
+use Webkul\BagistoApi\Models\Product;
+use Webkul\BagistoApi\Models\ProductQuestion;
+
+class ProductQuestionProcessor implements ProcessorInterface
+{
+    public function __construct(
+        private readonly ProcessorInterface $persistProcessor
+    ) {}
+
+    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
+    {
+        if ($data instanceof CreateProductQuestionInput) {
+            return $this->handleCreate($data);
+        }
+
+        if ($operation instanceof Patch) {
+            return $this->handleUpdate($data, $uriVariables);
+        }
+
+        if ($operation instanceof Delete) {
+            return $this->handleDelete($data, $uriVariables, $context);
+        }
+
+        if ($operation instanceof Post) {
+            return $this->handleRestCreate($context);
+        }
+
+        return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
+    }
+
+    private function handleCreate(CreateProductQuestionInput $data): ProductQuestion
+    {
+        $customer = Auth::guard('sanctum')->user();
+        if (! $customer) {
+            throw new AuthenticationException('You must be logged in to ask a question.');
+        }
+
+        $this->validateProductExists($data->productId);
+
+        if (empty(trim($data->question))) {
+            throw new InvalidInputException('Question text is required.');
+        }
+
+        $question = new ProductQuestion;
+        $question->setAttribute('product_id', $data->productId);
+        $question->setAttribute('question', $data->question);
+        $question->setAttribute('customer_name', $data->customerName ?: $customer->name);
+        $question->setAttribute('customer_email', $data->customerEmail ?? $customer->email);
+        $question->setAttribute('customer_id', $customer->id);
+        $question->setAttribute('status', 'pending');
+
+        $question->save();
+
+        return $question;
+    }
+
+    private function handleRestCreate(array $context): ProductQuestion
+    {
+        $customer = Auth::guard('sanctum')->user();
+        if (! $customer) {
+            throw new AuthenticationException('You must be logged in to ask a question.');
+        }
+
+        $request = $context['request'] ?? request();
+        $allData = method_exists($request, 'all') ? $request->all() : [];
+
+        $productId = $allData['product_id'] ?? null;
+        if (! $productId) {
+            throw new InvalidInputException('product_id is required.');
+        }
+
+        $this->validateProductExists((int) $productId);
+
+        $questionText = $allData['question'] ?? '';
+        if (empty(trim($questionText))) {
+            throw new InvalidInputException('Question text is required.');
+        }
+
+        $question = new ProductQuestion;
+        $question->setAttribute('product_id', (int) $productId);
+        $question->setAttribute('question', $questionText);
+        $question->setAttribute('customer_name', $allData['customer_name'] ?? $customer->name);
+        $question->setAttribute('customer_email', $allData['customer_email'] ?? $customer->email);
+        $question->setAttribute('customer_id', $customer->id);
+        $question->setAttribute('status', 'pending');
+
+        $question->save();
+
+        return $question;
+    }
+
+    /**
+     * Admin or owner status update / field update
+     */
+    private function handleUpdate(mixed $data, array $uriVariables): ProductQuestion
+    {
+        $id = $uriVariables['id'] ?? (is_object($data) && isset($data->id) ? $data->id : null);
+        $question = ProductQuestion::find((int) $id);
+
+        if (! $question) {
+            throw new ResourceNotFoundException('Question not found.');
+        }
+
+        $request = request();
+        $allData = $request->all();
+
+        // status management (admin-facing field)
+        if (isset($allData['status']) && in_array($allData['status'], ['pending', 'approved', 'rejected'], true)) {
+            $question->setAttribute('status', $allData['status']);
+        }
+
+        $question->save();
+
+        return $question;
+    }
+
+    private function handleDelete(mixed $data, array $uriVariables, array $context): mixed
+    {
+        $id = $uriVariables['id'] ?? (is_object($data) ? $data->id : null);
+        $question = ProductQuestion::find((int) $id);
+
+        if (! $question) {
+            throw new ResourceNotFoundException('Question not found.');
+        }
+
+        $question->delete();
+
+        return null;
+    }
+
+    private function validateProductExists(int $productId): void
+    {
+        if (! Product::find($productId)) {
+            throw new ResourceNotFoundException('Product not found.');
+        }
+    }
+}

+ 161 - 0
packages/Webkul/BagistoApi/src/State/ProductQuestionProvider.php

@@ -0,0 +1,161 @@
+<?php
+
+namespace Webkul\BagistoApi\State;
+
+use ApiPlatform\Laravel\Eloquent\Paginator;
+use ApiPlatform\Metadata\Operation;
+use ApiPlatform\State\Pagination\Pagination;
+use ApiPlatform\State\ProviderInterface;
+use Illuminate\Pagination\LengthAwarePaginator;
+use Webkul\BagistoApi\Models\ProductQuestion;
+use Webkul\BagistoApi\Models\ProductQuestionAnswer;
+
+class ProductQuestionProvider implements ProviderInterface
+{
+    public function __construct(
+        private readonly Pagination $pagination
+    ) {}
+
+    public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
+    {
+        $args = $context['args'] ?? [];
+        $request = request();
+
+        $query = ProductQuestion::query();
+
+        // Filter by product_id from URI variables (nested route) or query param
+        $productId = $uriVariables['productId'] ?? $args['product_id'] ?? $request->query('product_id');
+        if ($productId) {
+            $query->where('product_id', (int) $productId);
+        }
+
+        // Storefront shows only approved questions by default
+        $status = $args['status'] ?? $request->query('status', 'approved');
+        $query->where('status', $status);
+
+        $query->with(['customer']);
+        $query->orderByDesc('created_at');
+
+        // ---------------------------------------------------------------
+        // Embedded answers options
+        // with_answers=true (default)  → embed top N answers per question
+        // answers_per_question=5       → how many answers to embed (max 20)
+        // answer_status=approved       → which answer status to embed
+        // ---------------------------------------------------------------
+        $withAnswers = filter_var(
+            $args['with_answers'] ?? $request->query('with_answers', 'true'),
+            FILTER_VALIDATE_BOOLEAN
+        );
+        $answersPerQuestion = min(20, max(1, (int) (
+            $args['answers_per_question'] ?? $request->query('answers_per_question', 5)
+        )));
+        $answerStatus = $args['answer_status'] ?? $request->query('answer_status', 'approved');
+
+        // REST pagination
+        $page = max(1, (int) ($request->query('page', 1)));
+        $perPage = min(50, max(1, (int) ($request->query('per_page', $request->query('limit', 30)))));
+
+        // GraphQL cursor pagination
+        $first = isset($args['first']) ? (int) $args['first'] : null;
+        $last = isset($args['last']) ? (int) $args['last'] : null;
+        $after = $args['after'] ?? null;
+        $before = $args['before'] ?? null;
+
+        if ($first || $last || $after || $before) {
+            $perPage = $first ?? $last ?? 30;
+            $offset = 0;
+
+            if ($after) {
+                $decoded = base64_decode($after, true);
+                $offset = ctype_digit((string) $decoded) ? ((int) $decoded + 1) : 0;
+            }
+
+            if ($before) {
+                $decoded = base64_decode($before, true);
+                $cursor = ctype_digit((string) $decoded) ? (int) $decoded : 0;
+                $offset = max(0, $cursor - $perPage);
+            }
+
+            $total = (clone $query)->count();
+            $items = $query->offset($offset)->limit($perPage)->get();
+            $currentPage = $total > 0 ? (int) floor($offset / $perPage) + 1 : 1;
+        } else {
+            $total = (clone $query)->count();
+            $offset = ($page - 1) * $perPage;
+            $items = $query->offset($offset)->limit($perPage)->get();
+            $currentPage = $page;
+        }
+
+        // Attach answers preview to each question in a single batch query (no N+1)
+        if ($withAnswers && $items->isNotEmpty()) {
+            $this->attachAnswersPreview($items, $answersPerQuestion, $answerStatus);
+        } else {
+            $items->each(function ($question) {
+                $question->setAttribute('answers_preview', []);
+                $question->setAttribute('answers_meta', [
+                    'total'        => $question->answers_count,
+                    'has_more'     => false,
+                    'page_size'    => 0,
+                ]);
+            });
+        }
+
+        return new Paginator(
+            new LengthAwarePaginator(
+                $items,
+                $total,
+                $perPage,
+                $currentPage,
+                ['path' => request()->url()]
+            )
+        );
+    }
+
+    /**
+     * Load top N answers for all questions in one query, group by question_id,
+     * and attach as `answers_preview` + `answers_meta` attributes.
+     *
+     * Sort order: pinned first → useful_count desc → newest first.
+     * This avoids N+1 — only one extra DB query for the whole page.
+     */
+    private function attachAnswersPreview(
+        \Illuminate\Support\Collection $questions,
+        int $limit,
+        string $status
+    ): void {
+        $questionIds = $questions->pluck('id')->toArray();
+
+        // Fetch enough rows to let us take $limit per question after grouping.
+        // We over-fetch by $limit × question count so we can slice in PHP.
+        $answers = ProductQuestionAnswer::whereIn('question_id', $questionIds)
+            ->where('status', $status)
+            ->orderByDesc('is_pinned')
+            ->orderByDesc('useful_count')
+            ->orderByDesc('created_at')
+            ->get(['id', 'question_id', 'customer_id', 'customer_name', 'answer',
+                   'status', 'is_pinned', 'useful_count', 'created_at', 'updated_at']);
+
+        // Total counts per question (separate lightweight query)
+        $totalCounts = ProductQuestionAnswer::whereIn('question_id', $questionIds)
+            ->where('status', $status)
+            ->groupBy('question_id')
+            ->selectRaw('question_id, COUNT(*) as cnt')
+            ->pluck('cnt', 'question_id');
+
+        // Group and slice to top N per question
+        $grouped = $answers->groupBy('question_id')
+            ->map(fn ($group) => $group->take($limit)->values());
+
+        $questions->each(function ($question) use ($grouped, $totalCounts, $limit) {
+            $preview = $grouped->get($question->id, collect());
+            $total   = (int) ($totalCounts->get($question->id, 0));
+
+            $question->setAttribute('answers_preview', $preview->toArray());
+            $question->setAttribute('answers_meta', [
+                'total'     => $total,
+                'has_more'  => $total > $limit,
+                'page_size' => $limit,
+            ]);
+        });
+    }
+}

+ 6 - 1
packages/Webkul/BagistoApi/src/State/ProductRestProvider.php

@@ -23,12 +23,13 @@ use ApiPlatform\Metadata\Operation;
  *   ?color=3&size=6             → args.filter.color, size (filterable attributes)
  *   ?filter={"color":{...}}     → merged into args.filter
  *   ?locale=en&channel=default  → args.locale, args.channel
+ *   ?suggest=1                  → args.suggest=true
  */
 class ProductRestProvider extends ProductGraphQLProvider
 {
     private const RESERVED_KEYS = [
         'query', 'sort', 'order', 'page', 'per_page',
-        'locale', 'channel', 'filter',
+        'locale', 'channel', 'filter', 'suggest',
     ];
 
     public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
@@ -41,6 +42,10 @@ class ProductRestProvider extends ProductGraphQLProvider
             $args['query'] = (string) $query['query'];
         }
 
+        if (isset($query['suggest'])) {
+            $args['suggest'] = filter_var($query['suggest'], FILTER_VALIDATE_BOOLEAN);
+        }
+
         [$sortKey, $reverse] = $this->parseSort($query['sort'] ?? null, $query['order'] ?? null);
         if ($sortKey !== null) {
             $args['sortKey'] = $sortKey;

+ 64 - 0
packages/Webkul/BagistoApi/src/State/SaveCheckoutCartProcessor.php

@@ -5,6 +5,7 @@ namespace Webkul\BagistoApi\State;
 use ApiPlatform\Metadata\Operation;
 use ApiPlatform\State\ProcessorInterface;
 use Illuminate\Support\Facades\Request;
+use Longyi\Gift\Models\GiftCards;
 use Webkul\BagistoApi\Dto\CartData;
 use Webkul\BagistoApi\Dto\SaveCheckoutCartInput;
 use Webkul\BagistoApi\Exception\AuthenticationException;
@@ -15,6 +16,8 @@ use Webkul\BagistoApi\Facades\TokenHeaderFacade;
 use Webkul\CartRule\Repositories\CartRuleCouponRepository;
 use Webkul\Checkout\Facades\Cart;
 use Webkul\Shipping\Facades\Shipping;
+use Longyi\Gift\Services\GiftCardService;
+use Longyi\Member\Services\MemberDiscountService;
 
 /**
  * GraphQL processor for the SaveCheckoutCart mutation.
@@ -32,6 +35,8 @@ class SaveCheckoutCartProcessor implements ProcessorInterface
 
     public function __construct(
         protected CartRuleCouponRepository $cartRuleCouponRepository,
+        protected GiftCardService $giftCardService,
+        protected MemberDiscountService $memberDiscountService,
     ) {}
 
     /**
@@ -71,6 +76,14 @@ class SaveCheckoutCartProcessor implements ProcessorInterface
                 $this->applyCoupon((string) $data->couponCode);
             }
 
+            if ($this->shouldUpdate($data->giftCardNumber)) {
+                $this->applyGiftCard((string) $data->giftCardNumber);
+            }
+
+            if ($this->shouldUpdate($data->memberDiscount)) {
+                $this->applyMemberDiscount((string) $data->memberDiscount);
+            }
+
             Cart::collectTotals();
         } catch (OperationFailedException $e) {
             throw $e;
@@ -161,4 +174,55 @@ class SaveCheckoutCartProcessor implements ProcessorInterface
             throw new OperationFailedException(trans('shop::app.checkout.coupon.error'));
         }
     }
+
+    /**
+     * Apply or remove a gift card. An empty string removes the active gift card.
+     */
+    private function applyGiftCard(string $code): void
+    {
+        $code = trim($code);
+
+        $customerId = Cart::getCart()?->customer_id;
+
+        if ($code === '') {
+            $this->giftCardService->remove($customerId);
+
+            return;
+        }
+
+        if (Cart::getCart()?->giftcard_number === $code) {
+            return;
+        }
+
+        if (! $customerId) {
+            throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.customer-not-found'));
+        }
+
+        $this->giftCardService->activate($code, $customerId);
+    }
+
+    /**
+     * Apply or remove member discount. "1" applies, empty string removes.
+     */
+    private function applyMemberDiscount(string $value): void
+    {
+        $value = trim($value);
+
+        if ($value === '') {
+            $this->memberDiscountService->remove();
+
+            return;
+        }
+        if (Cart::getCart()?->vip_plus_amount > 0) {
+            return;
+        }
+
+        $customerId = Cart::getCart()?->customer_id;
+
+        if (! $customerId) {
+            throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.customer-not-found'));
+        }
+
+        $this->memberDiscountService->apply();
+    }
 }

+ 141 - 0
packages/Webkul/BagistoApi/tests/Feature/GraphQL/MergeCartTest.php

@@ -0,0 +1,141 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Feature\GraphQL;
+
+use Webkul\BagistoApi\Models\StorefrontKey;
+use Webkul\BagistoApi\Tests\GraphQLTestCase;
+use Webkul\Product\Models\Product;
+
+class MergeCartTest extends GraphQLTestCase
+{
+    public function setUp(): void
+    {
+        parent::setUp();
+
+        $this->storefrontKey = StorefrontKey::generateKey();
+
+        StorefrontKey::query()->create([
+            'name'       => 'Merge Cart Test',
+            'key'        => $this->storefrontKey,
+            'is_active'  => true,
+            'rate_limit' => 1000,
+        ]);
+    }
+
+    public function test_merge_recalculates_totals_when_same_product_exists_in_both_carts(): void
+    {
+        $product = Product::query()
+            ->where('type', 'simple')
+            ->whereHas('inventories', fn ($query) => $query->where('qty', '>=', 3))
+            ->get()
+            ->first(fn (Product $product) => (bool) $product->status);
+
+        if (! $product) {
+            $this->markTestSkipped('A saleable simple product is required for this test.');
+        }
+
+        $guestCart = $this->createCart();
+        $this->addProduct($guestCart['token'], $product->id, 2);
+
+        $customerData = $this->createTestCustomer();
+        $this->addProduct($customerData['token'], $product->id, 1);
+
+        $mutation = <<<'GQL'
+            mutation mergeCart($cartId: Int!) {
+              createMergeCart(input: {cartId: $cartId}) {
+                mergeCart {
+                  success
+                  baseSubtotal
+                  items {
+                    edges {
+                      node {
+                        quantity
+                        basePrice
+                        baseTotal
+                      }
+                    }
+                  }
+                }
+              }
+            }
+        GQL;
+
+        $response = $this->graphQL($mutation, [
+            'cartId' => $guestCart['id'],
+        ], $this->customerHeaders($customerData['token']));
+
+        $response->assertSuccessful();
+
+        $cart = $response->json('data.createMergeCart.mergeCart');
+        $item = $cart['items']['edges'][0]['node'];
+
+        $this->assertTrue($cart['success']);
+        $this->assertSame(3, $item['quantity']);
+        $this->assertEqualsWithDelta(
+            (float) $item['basePrice'] * 3,
+            (float) $item['baseTotal'],
+            0.0001
+        );
+        $this->assertEqualsWithDelta(
+            (float) $item['baseTotal'],
+            (float) $cart['baseSubtotal'],
+            0.0001
+        );
+    }
+
+    private function createCart(): array
+    {
+        $mutation = <<<'GQL'
+            mutation createCart {
+              createCartToken(input: {}) {
+                cartToken {
+                  _id
+                  cartToken
+                }
+              }
+            }
+        GQL;
+
+        $response = $this->graphQL($mutation);
+        $response->assertSuccessful();
+
+        return [
+            'id'    => (int) $response->json('data.createCartToken.cartToken._id'),
+            'token' => (string) $response->json('data.createCartToken.cartToken.cartToken'),
+        ];
+    }
+
+    private function addProduct(string $token, int $productId, int $quantity): void
+    {
+        $mutation = <<<'GQL'
+            mutation addProduct($productId: Int!, $quantity: Int!) {
+              createAddProductInCart(input: {
+                productId: $productId
+                quantity: $quantity
+              }) {
+                addProductInCart {
+                  success
+                }
+              }
+            }
+        GQL;
+
+        $response = $this->graphQL($mutation, [
+            'productId' => $productId,
+            'quantity'  => $quantity,
+        ], $this->customerHeaders($token));
+
+        $response->assertSuccessful();
+        $this->assertTrue(
+            (bool) $response->json('data.createAddProductInCart.addProductInCart.success'),
+            $response->getContent()
+        );
+    }
+
+    private function customerHeaders(string $token): array
+    {
+        return [
+            'Authorization' => 'Bearer '.$token,
+        ];
+    }
+}

+ 194 - 0
packages/Webkul/BagistoApi/tests/Feature/ProductSearchGraphQLTest.php

@@ -0,0 +1,194 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Feature;
+
+use Illuminate\Foundation\Testing\DatabaseTransactions;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Testing\TestResponse;
+use Tests\TestCase;
+use Webkul\BagistoApi\Models\StorefrontKey;
+use Webkul\BagistoApi\Http\Middleware\LogApiRequests;
+
+class ProductSearchGraphQLTest extends TestCase
+{
+    use DatabaseTransactions;
+
+    private string $storefrontKey;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->withoutMiddleware(LogApiRequests::class);
+
+        $this->storefrontKey = StorefrontKey::generateKey();
+
+        StorefrontKey::query()->create([
+            'name'       => 'Product Search GraphQL Test',
+            'key'        => $this->storefrontKey,
+            'is_active'  => true,
+            'rate_limit' => 1000,
+        ]);
+
+        if (
+            core()->getConfigData('catalog.products.search.engine') !== 'elastic'
+            || core()->getConfigData('catalog.products.search.storefront_mode') !== 'elastic'
+        ) {
+            $this->markTestSkipped('The product search GraphQL feature requires the Elasticsearch storefront mode.');
+        }
+    }
+
+    private function graphQL(string $query, array $variables = []): TestResponse
+    {
+        return $this->postJson('/api/graphql', [
+            'query'     => $query,
+            'variables' => $variables,
+        ], [
+            'X-STOREFRONT-KEY' => $this->storefrontKey,
+        ]);
+    }
+
+    public function test_search_returns_correction_metadata_inside_data_and_logs_once(): void
+    {
+        $before = DB::table('search_log')->count();
+
+        $response = $this->graphQL(<<<'GQL'
+            query ProductSearchCorrection {
+              search(query: "readyy", suggest: true, first: 1) {
+                totalCount
+                edges {
+                  cursor
+                  node { id sku name }
+                }
+                pageInfo {
+                  hasNextPage
+                  hasPreviousPage
+                  startCursor
+                  endCursor
+                }
+                searchMetadata {
+                  originalQuery
+                  effectiveQuery
+                  resultMessage
+                  searchInsteadMessage
+                }
+              }
+            }
+            GQL);
+
+        $response
+            ->assertOk()
+            ->assertJsonPath('data.search.searchMetadata.originalQuery', 'readyy')
+            ->assertJsonPath('data.search.searchMetadata.effectiveQuery', 'ready')
+            ->assertJsonMissingPath('extensions.searchMetadata');
+
+        $total = $response->json('data.search.totalCount');
+
+        if (! is_int($total) || $total < 1) {
+            $this->markTestSkipped('The Elasticsearch test index does not contain products matching "ready".');
+        }
+
+        $response->assertJsonStructure([
+            'data' => [
+                'search' => [
+                    'edges' => [['cursor', 'node' => ['id', 'sku', 'name']]],
+                    'pageInfo' => ['hasNextPage', 'hasPreviousPage', 'startCursor', 'endCursor'],
+                ],
+            ],
+        ]);
+
+        $this->assertSame($before + 1, DB::table('search_log')->count());
+        $this->assertDatabaseHas('search_log', [
+            'search_text' => 'readyy',
+            'query_text'  => 'ready',
+            'results'     => $total,
+        ]);
+    }
+
+    public function test_search_without_suggest_returns_null_metadata_and_does_not_log(): void
+    {
+        $before = DB::table('search_log')->count();
+
+        $response = $this->graphQL(<<<'GQL'
+            query ProductSearchWithoutSuggest {
+              search(query: "readyy", suggest: false, first: 1) {
+                totalCount
+                searchMetadata { effectiveQuery }
+              }
+            }
+            GQL);
+
+        $response
+            ->assertOk()
+            ->assertJsonPath('data.search.searchMetadata', null);
+
+        $this->assertIsInt($response->json('data.search.totalCount'));
+        $this->assertSame($before, DB::table('search_log')->count());
+    }
+
+    public function test_search_preserves_filtering_and_cursor_pagination(): void
+    {
+        $page = $this->graphQL(<<<'GQL'
+            query ProductSearchPage {
+              search(query: "ready", first: 1) {
+                totalCount
+                edges { cursor node { sku } }
+                pageInfo { hasNextPage hasPreviousPage }
+              }
+            }
+            GQL);
+
+        $page
+            ->assertOk()
+            ->assertJsonPath('data.search.pageInfo.hasPreviousPage', false);
+
+        $total = $page->json('data.search.totalCount');
+        $cursor = $page->json('data.search.edges.0.cursor');
+        $firstSku = $page->json('data.search.edges.0.node.sku');
+
+        if (! is_int($total) || $total < 1 || ! is_string($cursor) || ! is_string($firstSku)) {
+            $this->markTestSkipped('The Elasticsearch test index does not contain products matching "ready".');
+        }
+
+        $page->assertJsonPath('data.search.pageInfo.hasNextPage', $total > 1);
+
+        if ($total > 1) {
+            $nextPage = $this->graphQL(
+                <<<'GQL'
+                    query ProductSearchNextPage($after: String!) {
+                      search(query: "ready", first: 1, after: $after) {
+                        totalCount
+                        edges { node { sku } }
+                        pageInfo { hasNextPage hasPreviousPage }
+                      }
+                    }
+                    GQL,
+                ['after' => $cursor]
+            );
+
+            $nextPage
+                ->assertOk()
+                ->assertJsonPath('data.search.totalCount', $total)
+                ->assertJsonPath('data.search.pageInfo.hasPreviousPage', true);
+
+            $this->assertNotSame($firstSku, $nextPage->json('data.search.edges.0.node.sku'));
+        }
+
+        $filtered = $this->graphQL(
+            <<<'GQL'
+                query ProductSearchFilter($filter: String!) {
+                  search(query: "ready", filter: $filter, first: 10) {
+                    totalCount
+                    edges { node { sku } }
+                  }
+                }
+                GQL,
+            ['filter' => json_encode(['sku' => $firstSku])]
+        );
+
+        $filtered
+            ->assertOk()
+            ->assertJsonPath('data.search.totalCount', 1)
+            ->assertJsonPath('data.search.edges.0.node.sku', $firstSku);
+    }
+}

+ 66 - 0
packages/Webkul/BagistoApi/tests/Unit/Http/Middleware/SearchMetadataResponseTest.php

@@ -0,0 +1,66 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Unit\Http\Middleware;
+
+use Illuminate\Http\Request;
+use Illuminate\Http\JsonResponse;
+use Tests\TestCase;
+use Webkul\BagistoApi\Http\Middleware\SearchMetadataResponse;
+
+class SearchMetadataResponseTest extends TestCase
+{
+    public function test_it_adds_search_metadata_to_rest_response_meta(): void
+    {
+        $request = Request::create('/api/shop/products', 'GET');
+        $request->attributes->set('bagistoapi.search_metadata', $this->metadata());
+
+        $response = (new SearchMetadataResponse())->handle(
+            $request,
+            fn () => response()->json(['member' => []])
+        );
+
+        $this->assertInstanceOf(JsonResponse::class, $response);
+        $this->assertSame($this->metadata(), $response->getData(true)['meta']['searchMetadata']);
+    }
+
+    public function test_it_adds_search_metadata_to_graphql_extensions(): void
+    {
+        $request = Request::create('/api/graphql', 'POST');
+        $request->attributes->set('bagistoapi.search_metadata', $this->metadata());
+
+        $response = (new SearchMetadataResponse())->handle(
+            $request,
+            fn () => response()->json(['data' => ['products' => []]])
+        );
+
+        $this->assertInstanceOf(JsonResponse::class, $response);
+        $this->assertSame($this->metadata(), $response->getData(true)['extensions']['searchMetadata']);
+    }
+
+    public function test_it_supports_api_platform_symfony_json_responses(): void
+    {
+        $request = Request::create('/api/graphql', 'POST');
+        $request->attributes->set('bagistoapi.search_metadata', $this->metadata());
+
+        $response = (new SearchMetadataResponse())->handle(
+            $request,
+            fn () => new \Symfony\Component\HttpFoundation\JsonResponse([
+                'data' => ['products' => []],
+            ])
+        );
+
+        $payload = json_decode((string) $response->getContent(), true);
+
+        $this->assertSame($this->metadata(), $payload['extensions']['searchMetadata']);
+    }
+
+    private function metadata(): array
+    {
+        return [
+            'originalQuery'        => 'readyy',
+            'effectiveQuery'       => 'ready',
+            'resultMessage'        => 'These are results for: ready',
+            'searchInsteadMessage' => 'Search instead for readyy',
+        ];
+    }
+}

+ 97 - 0
packages/Webkul/BagistoApi/tests/Unit/Payments/PaypalWebhookControllerTest.php

@@ -0,0 +1,97 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Unit\Payments;
+
+use Illuminate\Http\Request;
+use Mockery;
+use Tests\TestCase;
+use Webkul\Paypal\Http\Controllers\WebhookController;
+use Webkul\Paypal\Services\WebhookService;
+use Webkul\Paypal\Services\WebhookVerifier;
+
+class PaypalWebhookControllerTest extends TestCase
+{
+    protected function tearDown(): void
+    {
+        Mockery::close();
+        parent::tearDown();
+    }
+
+    private function request(array $event): Request
+    {
+        return Request::create(
+            '/paypal/smart-button/webhook',
+            'POST',
+            [],
+            [],
+            [],
+            [
+                'CONTENT_TYPE'                   => 'application/json',
+                'HTTP_PAYPAL_AUTH_ALGO'          => 'SHA256withRSA',
+                'HTTP_PAYPAL_CERT_URL'           => 'https://api.paypal.com/cert.pem',
+                'HTTP_PAYPAL_TRANSMISSION_ID'    => 'transmission-1',
+                'HTTP_PAYPAL_TRANSMISSION_SIG'   => 'signature',
+                'HTTP_PAYPAL_TRANSMISSION_TIME'  => '2026-08-10T08:00:00Z',
+            ],
+            json_encode($event, JSON_THROW_ON_ERROR),
+        );
+    }
+
+    public function test_it_acknowledges_a_verified_unknown_event(): void
+    {
+        $verifier = Mockery::mock(WebhookVerifier::class);
+        $verifier->shouldReceive('verify')->once()->andReturnTrue();
+
+        $service = Mockery::mock(WebhookService::class);
+        $service->shouldNotReceive('handleCaptureCompleted');
+
+        $controller = new WebhookController($verifier, $service);
+        $response = $controller->hook($this->request([
+            'id'         => 'WH-EVENT-IGNORED',
+            'event_type' => 'CHECKOUT.ORDER.APPROVED',
+        ]));
+
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame('ignored', $response->getData(true)['status']);
+    }
+
+    public function test_it_rejects_an_invalid_signature(): void
+    {
+        $verifier = Mockery::mock(WebhookVerifier::class);
+        $verifier->shouldReceive('verify')->once()->andReturnFalse();
+
+        $controller = new WebhookController(
+            $verifier,
+            Mockery::mock(WebhookService::class),
+        );
+
+        $response = $controller->hook($this->request([
+            'id'         => 'WH-EVENT-BAD-SIGNATURE',
+            'event_type' => 'PAYMENT.CAPTURE.COMPLETED',
+        ]));
+
+        $this->assertSame(400, $response->getStatusCode());
+        $this->assertSame('invalid_signature', $response->getData(true)['status']);
+    }
+
+    public function test_it_returns_a_retryable_error_when_processing_fails(): void
+    {
+        $verifier = Mockery::mock(WebhookVerifier::class);
+        $verifier->shouldReceive('verify')->once()->andReturnTrue();
+
+        $service = Mockery::mock(WebhookService::class);
+        $service->shouldReceive('handleCaptureCompleted')
+            ->once()
+            ->andThrow(new \RuntimeException('temporary database failure'));
+
+        $controller = new WebhookController($verifier, $service);
+        $response = $controller->hook($this->request([
+            'id'         => 'WH-EVENT-RETRY',
+            'event_type' => 'PAYMENT.CAPTURE.COMPLETED',
+            'resource'   => [],
+        ]));
+
+        $this->assertSame(500, $response->getStatusCode());
+        $this->assertSame('processing_failed', $response->getData(true)['status']);
+    }
+}

+ 167 - 0
packages/Webkul/BagistoApi/tests/Unit/Payments/PaypalWebhookServiceTest.php

@@ -0,0 +1,167 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Unit\Payments;
+
+use Mockery;
+use Tests\TestCase;
+use Webkul\BagistoApi\Models\PaymentAttempt;
+use Webkul\BagistoApi\Repositories\PaymentAttemptRepository;
+use Webkul\BagistoApi\Services\PaymentService;
+use Webkul\Paypal\Services\WebhookService;
+use Webkul\Sales\Repositories\OrderRepository;
+
+class PaypalWebhookServiceTest extends TestCase
+{
+    protected function tearDown(): void
+    {
+        Mockery::close();
+        parent::tearDown();
+    }
+
+    private function event(array $overrides = []): array
+    {
+        return array_replace_recursive([
+            'id'         => 'WH-EVENT-1',
+            'event_type' => 'PAYMENT.CAPTURE.COMPLETED',
+            'resource'   => [
+                'id'     => 'CAPTURE-1',
+                'status' => 'COMPLETED',
+                'amount' => [
+                    'value'         => '100.00',
+                    'currency_code' => 'USD',
+                ],
+                'supplementary_data' => [
+                    'related_ids' => [
+                        'order_id' => 'PAYPAL-ORDER-1',
+                    ],
+                ],
+            ],
+        ], $overrides);
+    }
+
+    public function test_it_resolves_and_settles_a_completed_capture(): void
+    {
+        $attempt = new PaymentAttempt([
+            'order_id'       => 10,
+            'payment_method' => 'paypal_smart_button',
+        ]);
+        $order = (object) [
+            'id'      => 10,
+            'payment' => (object) [
+                'method'     => 'paypal_smart_button',
+                'additional' => ['paypal_order_id' => 'PAYPAL-ORDER-1'],
+            ],
+        ];
+
+        $attempts = Mockery::mock(PaymentAttemptRepository::class);
+        $attempts->shouldReceive('findByGatewayOrderId')
+            ->once()
+            ->with('PAYPAL-ORDER-1')
+            ->andReturn($attempt);
+
+        $orders = Mockery::mock(OrderRepository::class);
+        $orders->shouldReceive('find')->once()->with(10)->andReturn($order);
+
+        $paymentService = Mockery::mock(PaymentService::class);
+        $paymentService->shouldReceive('completeWebhookCapture')
+            ->once()
+            ->withArgs(function ($actualOrder, $gatewayOrderId, array $capture, $eventId) use ($order) {
+                return $actualOrder === $order
+                    && $gatewayOrderId === 'PAYPAL-ORDER-1'
+                    && $capture['transaction_id'] === 'CAPTURE-1'
+                    && $capture['capture_status'] === 'COMPLETED'
+                    && $capture['amount'] === 100.0
+                    && $capture['currency'] === 'USD'
+                    && $eventId === 'WH-EVENT-1';
+            })
+            ->andReturn(['gatewayStatus' => 'captured', 'order' => $order]);
+
+        $service = new WebhookService($attempts, $orders, $paymentService);
+        $result = $service->handleCaptureCompleted($this->event());
+
+        $this->assertSame('captured', $result['gatewayStatus']);
+    }
+
+    public function test_it_rejects_an_event_without_a_related_paypal_order(): void
+    {
+        $service = new WebhookService(
+            Mockery::mock(PaymentAttemptRepository::class),
+            Mockery::mock(OrderRepository::class),
+            Mockery::mock(PaymentService::class),
+        );
+
+        $event = $this->event();
+        unset($event['resource']['supplementary_data']);
+
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionMessage('PayPal order id is missing');
+
+        $service->handleCaptureCompleted($event);
+    }
+
+    public function test_it_rejects_a_gateway_order_mismatch(): void
+    {
+        $attempt = new PaymentAttempt([
+            'order_id'       => 10,
+            'payment_method' => 'paypal_smart_button',
+        ]);
+        $order = (object) [
+            'id'      => 10,
+            'payment' => (object) [
+                'method'     => 'paypal_smart_button',
+                'additional' => ['paypal_order_id' => 'ANOTHER-ORDER'],
+            ],
+        ];
+
+        $attempts = Mockery::mock(PaymentAttemptRepository::class);
+        $attempts->shouldReceive('findByGatewayOrderId')->andReturn($attempt);
+
+        $orders = Mockery::mock(OrderRepository::class);
+        $orders->shouldReceive('find')->andReturn($order);
+
+        $service = new WebhookService(
+            $attempts,
+            $orders,
+            Mockery::mock(PaymentService::class),
+        );
+
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionMessage('does not match');
+
+        $service->handleCaptureCompleted($this->event());
+    }
+
+    public function test_it_rejects_a_non_completed_capture(): void
+    {
+        $attempt = new PaymentAttempt([
+            'order_id'       => 10,
+            'payment_method' => 'paypal_smart_button',
+        ]);
+        $order = (object) [
+            'id'      => 10,
+            'payment' => (object) [
+                'method'     => 'paypal_smart_button',
+                'additional' => ['paypal_order_id' => 'PAYPAL-ORDER-1'],
+            ],
+        ];
+
+        $attempts = Mockery::mock(PaymentAttemptRepository::class);
+        $attempts->shouldReceive('findByGatewayOrderId')->andReturn($attempt);
+
+        $orders = Mockery::mock(OrderRepository::class);
+        $orders->shouldReceive('find')->andReturn($order);
+
+        $service = new WebhookService(
+            $attempts,
+            $orders,
+            Mockery::mock(PaymentService::class),
+        );
+
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionMessage('not completed');
+
+        $service->handleCaptureCompleted($this->event([
+            'resource' => ['status' => 'PENDING'],
+        ]));
+    }
+}

+ 111 - 0
packages/Webkul/BagistoApi/tests/Unit/Payments/PaypalWebhookVerifierTest.php

@@ -0,0 +1,111 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Unit\Payments;
+
+use Illuminate\Http\Client\Request;
+use Illuminate\Support\Facades\Http;
+use Tests\TestCase;
+use Webkul\Paypal\Services\WebhookVerifier;
+
+class PaypalWebhookVerifierTest extends TestCase
+{
+    private function verifier(array $config): WebhookVerifier
+    {
+        return new class($config) extends WebhookVerifier
+        {
+            public function __construct(protected array $config) {}
+
+            protected function getConfigData(string $key): mixed
+            {
+                return $this->config[$key] ?? null;
+            }
+        };
+    }
+
+    private function headers(): array
+    {
+        return [
+            'PayPal-Auth-Algo'         => 'SHA256withRSA',
+            'PayPal-Cert-Url'          => 'https://api.paypal.com/cert.pem',
+            'PayPal-Transmission-Id'   => 'transmission-1',
+            'PayPal-Transmission-Sig'  => 'signature',
+            'PayPal-Transmission-Time' => '2026-08-10T08:00:00Z',
+        ];
+    }
+
+    public function test_it_verifies_a_sandbox_webhook_with_oauth(): void
+    {
+        Http::fake([
+            'https://api-m.sandbox.paypal.com/v1/oauth2/token' => Http::response([
+                'access_token' => 'access-token',
+            ]),
+            'https://api-m.sandbox.paypal.com/v1/notifications/verify-webhook-signature' => Http::response([
+                'verification_status' => 'SUCCESS',
+            ]),
+        ]);
+
+        $verifier = $this->verifier([
+            'client_id'     => 'client-id',
+            'client_secret' => 'client-secret',
+            'webhook_id'    => 'WH-123',
+            'sandbox'       => true,
+        ]);
+
+        $event = ['id' => 'WH-EVENT-1', 'event_type' => 'PAYMENT.CAPTURE.COMPLETED'];
+
+        $this->assertTrue($verifier->verify($this->headers(), $event));
+
+        Http::assertSentCount(2);
+        Http::assertSent(function (Request $request) use ($event) {
+            return $request->url() === 'https://api-m.sandbox.paypal.com/v1/notifications/verify-webhook-signature'
+                && $request['webhook_id'] === 'WH-123'
+                && $request['transmission_id'] === 'transmission-1'
+                && $request['webhook_event'] === $event
+                && $request->hasHeader('Authorization', 'Bearer access-token');
+        });
+    }
+
+    public function test_it_uses_the_live_api_when_sandbox_is_disabled(): void
+    {
+        Http::fake([
+            'https://api-m.paypal.com/v1/oauth2/token'                           => Http::response(['access_token' => 'live-token']),
+            'https://api-m.paypal.com/v1/notifications/verify-webhook-signature' => Http::response([
+                'verification_status' => 'FAILURE',
+            ]),
+        ]);
+
+        $verifier = $this->verifier([
+            'client_id'     => 'client-id',
+            'client_secret' => 'client-secret',
+            'webhook_id'    => 'WH-LIVE',
+            'sandbox'       => false,
+        ]);
+
+        $this->assertFalse($verifier->verify($this->headers(), ['id' => 'WH-EVENT-2']));
+
+        Http::assertSent(fn (Request $request) => str_starts_with($request->url(), 'https://api-m.paypal.com/'));
+    }
+
+    public function test_it_rejects_missing_signature_headers_without_calling_paypal(): void
+    {
+        Http::fake();
+
+        $verifier = $this->verifier([
+            'client_id'     => 'client-id',
+            'client_secret' => 'client-secret',
+            'webhook_id'    => 'WH-123',
+            'sandbox'       => true,
+        ]);
+
+        $headers = $this->headers();
+        unset($headers['PayPal-Transmission-Sig']);
+
+        $this->expectException(\InvalidArgumentException::class);
+
+        try {
+            $verifier->verify($headers, ['id' => 'WH-EVENT-3']);
+        } finally {
+            Http::assertNothingSent();
+        }
+    }
+}

+ 133 - 0
packages/Webkul/BagistoApi/tests/Unit/Repositories/ElasticSearchRepositoryTest.php

@@ -0,0 +1,133 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Unit\Repositories;
+
+use Mockery;
+use Tests\TestCase;
+use Webkul\Core\Facades\ElasticSearch;
+use Webkul\Product\Repositories\ElasticSearchRepository;
+
+class ElasticSearchRepositoryTest extends TestCase
+{
+    public function test_measurement_fallback_is_attempted_before_individual_terms(): void
+    {
+        $repository = app(ElasticSearchRepository::class);
+
+        ElasticSearch::shouldReceive('search')
+            ->times(4)
+            ->andReturn(
+                $this->emptySearchResponse(),
+                ['suggest' => ['name_suggest' => []]],
+                $this->emptySearchResponse(),
+                $this->searchResponse([42])
+            );
+
+        $result = $repository->searchForApi('28 inch purple water wave', true);
+
+        $this->assertSame('purple water wave', $result['query']);
+        $this->assertSame([42], $result['ids']);
+        $this->assertSame(1, $result['total']);
+    }
+
+    public function test_api_search_uses_exact_and_fuzzy_name_and_sku_clauses(): void
+    {
+        $repository = app(ElasticSearchRepository::class);
+
+        ElasticSearch::shouldReceive('search')
+            ->once()
+            ->with(Mockery::on(function (array $request) {
+                $should = $request['body']['query']['bool']['should'];
+
+                return count($should) === 1
+                    && $should[0]['multi_match']['fields'] === ['name^4', 'sku^6']
+                    && $should[0]['multi_match']['operator'] === 'and';
+            }))
+            ->ordered()
+            ->andReturn($this->emptySearchResponse());
+
+        ElasticSearch::shouldReceive('search')
+            ->once()
+            ->ordered()
+            ->andReturn(['suggest' => ['name_suggest' => []]]);
+
+        ElasticSearch::shouldReceive('search')
+            ->once()
+            ->with(Mockery::on(function (array $request) {
+                $should = $request['body']['query']['bool']['should'];
+
+                return $should[1]['multi_match']['fuzziness'] === 'AUTO';
+            }))
+            ->ordered()
+            ->andReturn($this->searchResponse([7]));
+
+        $result = $repository->searchForApi('purpel wave', true);
+
+        $this->assertSame('purpel wave', $result['query']);
+        $this->assertSame([7], $result['ids']);
+    }
+
+    public function test_typo_correction_is_retried_only_after_the_original_query_has_no_hits(): void
+    {
+        $repository = app(ElasticSearchRepository::class);
+
+        ElasticSearch::shouldReceive('search')
+            ->times(3)
+            ->andReturn(
+                $this->emptySearchResponse(),
+                [
+                    'suggest' => [
+                        'name_suggest' => [
+                            ['text' => 'purpel', 'options' => [['text' => 'purple']]],
+                            ['text' => 'wave', 'options' => []],
+                        ],
+                    ],
+                ],
+                $this->searchResponse([11])
+            );
+
+        $result = $repository->searchForApi('purpel wave', true);
+
+        $this->assertSame('purple wave', $result['query']);
+        $this->assertSame([11], $result['ids']);
+    }
+
+    public function test_suggest_opt_in_is_required_for_fuzzy_matching_and_correction(): void
+    {
+        $repository = app(ElasticSearchRepository::class);
+
+        ElasticSearch::shouldReceive('search')
+            ->once()
+            ->with(Mockery::on(function (array $request) {
+                $should = $request['body']['query']['bool']['should'];
+
+                return count($should) === 1
+                    && ! isset($should[0]['multi_match']['fuzziness']);
+            }))
+            ->andReturn($this->emptySearchResponse());
+
+        $result = $repository->searchForApi('readyy');
+
+        $this->assertSame('readyy', $result['query']);
+        $this->assertSame([], $result['ids']);
+    }
+
+    private function emptySearchResponse(): array
+    {
+        return [
+            'hits' => [
+                'total' => ['value' => 0],
+                'hits'  => [],
+            ],
+        ];
+    }
+
+    private function searchResponse(array $ids): array
+    {
+        return [
+            'hits' => [
+                'total' => ['value' => count($ids)],
+                'hits'  => array_map(fn (int $id) => ['_id' => (string) $id], $ids),
+            ],
+        ];
+    }
+}

+ 92 - 0
packages/Webkul/BagistoApi/tests/Unit/Services/SearchLogServiceTest.php

@@ -0,0 +1,92 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Unit\Services;
+
+use Illuminate\Http\Request;
+use Mockery;
+use Tests\TestCase;
+use Webkul\Marketing\Contracts\SearchLog;
+use Webkul\Marketing\Repositories\SearchLogRepository;
+use Webkul\Marketing\Services\SearchLogService;
+
+class SearchLogServiceTest extends TestCase
+{
+    public function test_it_records_normalized_search_details_and_request_ip(): void
+    {
+        $repository = Mockery::mock(SearchLogRepository::class);
+        $searchLog = Mockery::mock(SearchLog::class);
+        $request = Request::create(
+            '/api/shop/products?query=readyy&suggest=1',
+            'GET',
+            server: ['REMOTE_ADDR' => '203.0.113.10']
+        );
+
+        $repository->shouldReceive('create')
+            ->once()
+            ->with([
+                'search_text' => 'readyy',
+                'query_text'  => 'ready',
+                'results'     => 12,
+                'ip_address'  => '203.0.113.10',
+            ])
+            ->andReturn($searchLog);
+
+        $result = (new SearchLogService($repository))->record(
+            ' readyy ',
+            ' ready ',
+            12,
+            $request
+        );
+
+        $this->assertSame($searchLog, $result);
+    }
+
+    public function test_it_clamps_negative_result_counts_to_zero(): void
+    {
+        $repository = Mockery::mock(SearchLogRepository::class);
+        $searchLog = Mockery::mock(SearchLog::class);
+
+        $repository->shouldReceive('create')
+            ->once()
+            ->with(Mockery::on(fn (array $data) => $data['results'] === 0))
+            ->andReturn($searchLog);
+
+        (new SearchLogService($repository))->record(
+            'missing',
+            'missing',
+            -1,
+            Request::create('/')
+        );
+    }
+
+    public function test_it_only_records_when_suggest_is_enabled(): void
+    {
+        $repository = Mockery::mock(SearchLogRepository::class);
+        $searchLog = Mockery::mock(SearchLog::class);
+        $service = new SearchLogService($repository);
+
+        $repository->shouldNotReceive('create');
+
+        $this->assertNull($service->recordWhenSuggested(
+            false,
+            'readyy',
+            'ready',
+            12,
+            Request::create('/')
+        ));
+
+        $repository = Mockery::mock(SearchLogRepository::class);
+        $repository->shouldReceive('create')->once()->andReturn($searchLog);
+
+        $this->assertSame(
+            $searchLog,
+            (new SearchLogService($repository))->recordWhenSuggested(
+                '1',
+                'readyy',
+                'ready',
+                12,
+                Request::create('/')
+            )
+        );
+    }
+}

+ 4 - 0
packages/Webkul/Checkout/src/Cart.php

@@ -230,6 +230,10 @@ class Cart
                 'customer_email'      => $customer->email,
             ], $guestCart->id);
 
+            $this->setCart($this->cartRepository->find($guestCart->id));
+
+            $this->collectTotals();
+
             session()->forget('cart');
 
             return;

+ 5 - 0
packages/Webkul/Marketing/src/Contracts/SearchLog.php

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

+ 30 - 0
packages/Webkul/Marketing/src/Database/Migrations/2026_07_16_000000_create_search_log_table.php

@@ -0,0 +1,30 @@
+<?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('search_log', function (Blueprint $table) {
+            $table->bigIncrements('id');
+            $table->string('query_text', 512);
+            $table->string('search_text', 512);
+            $table->unsignedInteger('results')->default(0);
+            $table->string('ip_address', 45)->nullable();
+            $table->timestamp('created_at')->useCurrent();
+
+            $table->index('query_text');
+            $table->index('search_text');
+            $table->index('results');
+            $table->index('created_at');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('search_log');
+    }
+};

+ 25 - 0
packages/Webkul/Marketing/src/Models/SearchLog.php

@@ -0,0 +1,25 @@
+<?php
+
+namespace Webkul\Marketing\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Webkul\Marketing\Contracts\SearchLog as SearchLogContract;
+
+class SearchLog extends Model implements SearchLogContract
+{
+    public const UPDATED_AT = null;
+
+    protected $table = 'search_log';
+
+    protected $fillable = [
+        'query_text',
+        'search_text',
+        'results',
+        'ip_address',
+    ];
+
+    protected $casts = [
+        'results'    => 'integer',
+        'created_at' => 'datetime',
+    ];
+}

+ 7 - 0
packages/Webkul/Marketing/src/Models/SearchLogProxy.php

@@ -0,0 +1,7 @@
+<?php
+
+namespace Webkul\Marketing\Models;
+
+use Konekt\Concord\Proxies\ModelProxy;
+
+class SearchLogProxy extends ModelProxy {}

+ 0 - 0
packages/Webkul/Marketing/src/Providers/ModuleServiceProvider.php


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