浏览代码

问答模块

chengwl 3 天之前
父节点
当前提交
4b3048a23a
共有 34 个文件被更改,包括 3323 次插入0 次删除
  1. 15 0
      packages/Webkul/Admin/src/Config/acl.php
  2. 6 0
      packages/Webkul/Admin/src/Config/menu.php
  3. 162 0
      packages/Webkul/Admin/src/DataGrids/Catalog/ProductQuestionAnswerDataGrid.php
  4. 167 0
      packages/Webkul/Admin/src/DataGrids/Catalog/ProductQuestionDataGrid.php
  5. 295 0
      packages/Webkul/Admin/src/Http/Controllers/Catalog/ProductQuestionController.php
  6. 109 0
      packages/Webkul/Admin/src/Resources/lang/en/app.php
  7. 542 0
      packages/Webkul/Admin/src/Resources/views/catalog/questions/answers.blade.php
  8. 361 0
      packages/Webkul/Admin/src/Resources/views/catalog/questions/index.blade.php
  9. 35 0
      packages/Webkul/Admin/src/Routes/catalog-routes.php
  10. 34 0
      packages/Webkul/BagistoApi/src/Dto/CreateProductQuestionAnswerInput.php
  11. 34 0
      packages/Webkul/BagistoApi/src/Dto/CreateProductQuestionInput.php
  12. 5 0
      packages/Webkul/BagistoApi/src/Models/Product.php
  13. 265 0
      packages/Webkul/BagistoApi/src/Models/ProductQuestion.php
  14. 305 0
      packages/Webkul/BagistoApi/src/Models/ProductQuestionAnswer.php
  15. 47 0
      packages/Webkul/BagistoApi/src/Models/ProductQuestionAnswerVote.php
  16. 26 0
      packages/Webkul/BagistoApi/src/Providers/BagistoApiServiceProvider.php
  17. 227 0
      packages/Webkul/BagistoApi/src/State/ProductQuestionAnswerProcessor.php
  18. 87 0
      packages/Webkul/BagistoApi/src/State/ProductQuestionAnswerProvider.php
  19. 151 0
      packages/Webkul/BagistoApi/src/State/ProductQuestionProcessor.php
  20. 161 0
      packages/Webkul/BagistoApi/src/State/ProductQuestionProvider.php
  21. 7 0
      packages/Webkul/Product/src/Contracts/ProductQuestion.php
  22. 7 0
      packages/Webkul/Product/src/Contracts/ProductQuestionAnswer.php
  23. 7 0
      packages/Webkul/Product/src/Contracts/ProductQuestionAnswerVote.php
  24. 32 0
      packages/Webkul/Product/src/Database/Migrations/2026_08_03_000001_create_product_questions_table.php
  25. 34 0
      packages/Webkul/Product/src/Database/Migrations/2026_08_03_000002_create_product_question_answers_table.php
  26. 31 0
      packages/Webkul/Product/src/Database/Migrations/2026_08_03_000003_create_product_question_answer_votes_table.php
  27. 16 0
      packages/Webkul/Product/src/Models/Product.php
  28. 51 0
      packages/Webkul/Product/src/Models/ProductQuestion.php
  29. 45 0
      packages/Webkul/Product/src/Models/ProductQuestionAnswer.php
  30. 9 0
      packages/Webkul/Product/src/Models/ProductQuestionAnswerProxy.php
  31. 29 0
      packages/Webkul/Product/src/Models/ProductQuestionAnswerVote.php
  32. 9 0
      packages/Webkul/Product/src/Models/ProductQuestionAnswerVoteProxy.php
  33. 9 0
      packages/Webkul/Product/src/Models/ProductQuestionProxy.php
  34. 3 0
      packages/Webkul/Product/src/Providers/ModuleServiceProvider.php

+ 15 - 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,
     ],
 
     /*

+ 6 - 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'       => '',
     ],
 
     /**

+ 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'],
+                ],
+            ]);
+        }
+    }
+}

+ 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'),
+        ]);
+    }
+}

+ 109 - 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.',
@@ -4724,6 +4831,7 @@ return [
                 'attribute-families'       => 'Attribute Families',
                 'attributes'               => 'Attributes',
                 'booking-product'          => 'Bookings',
+                'product-questions'        => 'Product Q&A',
                 'campaigns'                => 'Campaigns',
                 'catalog'                  => 'Catalog',
                 'categories'               => 'Categories',
@@ -4984,6 +5092,7 @@ return [
         'attribute-families'       => 'Attribute Families',
         'attributes'               => 'Attributes',
         'campaigns'                => 'Campaigns',
+        'product-questions'        => 'Product Q&A',
         'cancel'                   => 'Cancel',
         'cart-rules'               => 'Cart Rules',
         'catalog'                  => 'Catalog',

+ 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>

+ 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');
+    });
 });

+ 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/Models/Product.php

@@ -1881,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');
+    }
+}

+ 26 - 0
packages/Webkul/BagistoApi/src/Providers/BagistoApiServiceProvider.php

@@ -197,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) {
@@ -294,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)
@@ -482,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)

+ 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,
+            ]);
+        });
+    }
+}

+ 7 - 0
packages/Webkul/Product/src/Contracts/ProductQuestion.php

@@ -0,0 +1,7 @@
+<?php
+
+namespace Webkul\Product\Contracts;
+
+interface ProductQuestion
+{
+}

+ 7 - 0
packages/Webkul/Product/src/Contracts/ProductQuestionAnswer.php

@@ -0,0 +1,7 @@
+<?php
+
+namespace Webkul\Product\Contracts;
+
+interface ProductQuestionAnswer
+{
+}

+ 7 - 0
packages/Webkul/Product/src/Contracts/ProductQuestionAnswerVote.php

@@ -0,0 +1,7 @@
+<?php
+
+namespace Webkul\Product\Contracts;
+
+interface ProductQuestionAnswerVote
+{
+}

+ 32 - 0
packages/Webkul/Product/src/Database/Migrations/2026_08_03_000001_create_product_questions_table.php

@@ -0,0 +1,32 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::create('product_questions', function (Blueprint $table) {
+            $table->increments('id');
+            $table->unsignedInteger('product_id');
+            $table->unsignedInteger('customer_id')->nullable();
+            $table->string('customer_name');
+            $table->string('customer_email')->nullable();
+            $table->text('question');
+            $table->enum('status', ['pending', 'approved', 'rejected'])->default('pending');
+            $table->unsignedInteger('answers_count')->default(0);
+            $table->timestamps();
+
+            $table->foreign('product_id')->references('id')->on('products')->onDelete('cascade');
+            $table->foreign('customer_id')->references('id')->on('customers')->onDelete('set null');
+            $table->index(['product_id', 'status']);
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('product_questions');
+    }
+};

+ 34 - 0
packages/Webkul/Product/src/Database/Migrations/2026_08_03_000002_create_product_question_answers_table.php

@@ -0,0 +1,34 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::create('product_question_answers', function (Blueprint $table) {
+            $table->increments('id');
+            $table->unsignedInteger('question_id');
+            $table->unsignedInteger('customer_id')->nullable();
+            $table->string('customer_name');
+            $table->string('customer_email')->nullable();
+            $table->text('answer');
+            $table->enum('status', ['pending', 'approved', 'rejected'])->default('pending');
+            $table->boolean('is_pinned')->default(false);
+            $table->unsignedInteger('useful_count')->default(0);
+            $table->timestamps();
+
+            $table->foreign('question_id')->references('id')->on('product_questions')->onDelete('cascade');
+            $table->foreign('customer_id')->references('id')->on('customers')->onDelete('set null');
+            $table->index(['question_id', 'status']);
+            $table->index(['question_id', 'is_pinned']);
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('product_question_answers');
+    }
+};

+ 31 - 0
packages/Webkul/Product/src/Database/Migrations/2026_08_03_000003_create_product_question_answer_votes_table.php

@@ -0,0 +1,31 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::create('product_question_answer_votes', function (Blueprint $table) {
+            $table->increments('id');
+            $table->unsignedInteger('answer_id');
+            $table->unsignedInteger('customer_id')->nullable();
+            $table->string('ip_address', 45)->nullable();
+            $table->timestamps();
+
+            $table->foreign('answer_id')->references('id')->on('product_question_answers')->onDelete('cascade');
+            $table->foreign('customer_id')->references('id')->on('customers')->onDelete('set null');
+
+            // Prevent duplicate votes: one per customer or one per IP (for guests)
+            $table->unique(['answer_id', 'customer_id']);
+            $table->index(['answer_id', 'ip_address']);
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('product_question_answer_votes');
+    }
+};

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

@@ -167,6 +167,22 @@ class Product extends Model implements ProductContract
         return $this->reviews()->where('status', 'approved');
     }
 
+    /**
+     * Get all product questions.
+     */
+    public function questions(): HasMany
+    {
+        return $this->hasMany(ProductQuestionProxy::modelClass());
+    }
+
+    /**
+     * Get approved product questions.
+     */
+    public function approvedQuestions(): HasMany
+    {
+        return $this->questions()->where('status', 'approved');
+    }
+
     /**
      * The inventory sources that belong to the product.
      */

+ 51 - 0
packages/Webkul/Product/src/Models/ProductQuestion.php

@@ -0,0 +1,51 @@
+<?php
+
+namespace Webkul\Product\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+use Webkul\Customer\Models\CustomerProxy;
+use Webkul\Product\Contracts\ProductQuestion as ProductQuestionContract;
+
+class ProductQuestion extends Model implements ProductQuestionContract
+{
+    protected $table = 'product_questions';
+
+    protected $fillable = [
+        'product_id',
+        'customer_id',
+        'customer_name',
+        'customer_email',
+        'question',
+        'status',
+        'answers_count',
+    ];
+
+    protected $casts = [
+        'answers_count' => 'int',
+    ];
+
+    public function product(): BelongsTo
+    {
+        return $this->belongsTo(ProductProxy::modelClass());
+    }
+
+    public function customer(): BelongsTo
+    {
+        return $this->belongsTo(CustomerProxy::modelClass());
+    }
+
+    public function answers(): HasMany
+    {
+        return $this->hasMany(ProductQuestionAnswerProxy::modelClass(), 'question_id');
+    }
+
+    public function approvedAnswers(): HasMany
+    {
+        return $this->hasMany(ProductQuestionAnswerProxy::modelClass(), 'question_id')
+            ->where('status', 'approved')
+            ->orderByDesc('is_pinned')
+            ->orderByDesc('useful_count');
+    }
+}

+ 45 - 0
packages/Webkul/Product/src/Models/ProductQuestionAnswer.php

@@ -0,0 +1,45 @@
+<?php
+
+namespace Webkul\Product\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+use Webkul\Customer\Models\CustomerProxy;
+use Webkul\Product\Contracts\ProductQuestionAnswer as ProductQuestionAnswerContract;
+
+class ProductQuestionAnswer extends Model implements ProductQuestionAnswerContract
+{
+    protected $table = 'product_question_answers';
+
+    protected $fillable = [
+        'question_id',
+        'customer_id',
+        'customer_name',
+        'customer_email',
+        'answer',
+        'status',
+        'is_pinned',
+        'useful_count',
+    ];
+
+    protected $casts = [
+        'is_pinned'    => 'bool',
+        'useful_count' => 'int',
+    ];
+
+    public function question(): BelongsTo
+    {
+        return $this->belongsTo(ProductQuestionProxy::modelClass(), 'question_id');
+    }
+
+    public function customer(): BelongsTo
+    {
+        return $this->belongsTo(CustomerProxy::modelClass());
+    }
+
+    public function votes(): HasMany
+    {
+        return $this->hasMany(ProductQuestionAnswerVoteProxy::modelClass(), 'answer_id');
+    }
+}

+ 9 - 0
packages/Webkul/Product/src/Models/ProductQuestionAnswerProxy.php

@@ -0,0 +1,9 @@
+<?php
+
+namespace Webkul\Product\Models;
+
+use Konekt\Concord\Proxies\ModelProxy;
+
+class ProductQuestionAnswerProxy extends ModelProxy
+{
+}

+ 29 - 0
packages/Webkul/Product/src/Models/ProductQuestionAnswerVote.php

@@ -0,0 +1,29 @@
+<?php
+
+namespace Webkul\Product\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Webkul\Customer\Models\CustomerProxy;
+use Webkul\Product\Contracts\ProductQuestionAnswerVote as ProductQuestionAnswerVoteContract;
+
+class ProductQuestionAnswerVote extends Model implements ProductQuestionAnswerVoteContract
+{
+    protected $table = 'product_question_answer_votes';
+
+    protected $fillable = [
+        'answer_id',
+        'customer_id',
+        'ip_address',
+    ];
+
+    public function answer(): BelongsTo
+    {
+        return $this->belongsTo(ProductQuestionAnswerProxy::modelClass(), 'answer_id');
+    }
+
+    public function customer(): BelongsTo
+    {
+        return $this->belongsTo(CustomerProxy::modelClass());
+    }
+}

+ 9 - 0
packages/Webkul/Product/src/Models/ProductQuestionAnswerVoteProxy.php

@@ -0,0 +1,9 @@
+<?php
+
+namespace Webkul\Product\Models;
+
+use Konekt\Concord\Proxies\ModelProxy;
+
+class ProductQuestionAnswerVoteProxy extends ModelProxy
+{
+}

+ 9 - 0
packages/Webkul/Product/src/Models/ProductQuestionProxy.php

@@ -0,0 +1,9 @@
+<?php
+
+namespace Webkul\Product\Models;
+
+use Konekt\Concord\Proxies\ModelProxy;
+
+class ProductQuestionProxy extends ModelProxy
+{
+}

+ 3 - 0
packages/Webkul/Product/src/Providers/ModuleServiceProvider.php

@@ -30,6 +30,9 @@ class ModuleServiceProvider extends CoreModuleServiceProvider
         \Webkul\Product\Models\ProductInventoryIndex::class,
         \Webkul\Product\Models\ProductOrderedInventory::class,
         \Webkul\Product\Models\ProductPriceIndex::class,
+        \Webkul\Product\Models\ProductQuestion::class,
+        \Webkul\Product\Models\ProductQuestionAnswer::class,
+        \Webkul\Product\Models\ProductQuestionAnswerVote::class,
         \Webkul\Product\Models\ProductReview::class,
         \Webkul\Product\Models\ProductReviewAttachment::class,
         \Webkul\Product\Models\ProductSalableInventory::class,