Просмотр исходного кода

Merge branch 'dev' into dev-rewardPoints

bianjunhui 3 месяцев назад
Родитель
Сommit
6ecce24dda
100 измененных файлов с 7349 добавлено и 354 удалено
  1. 1 0
      .gitignore
  2. 3 1
      bootstrap/providers.php
  3. 4 0
      composer.json
  4. 269 121
      composer.lock
  5. 7 1
      config/logging.php
  6. 234 0
      config/media-library.php
  7. 19 0
      config/visitor.php
  8. 32 0
      database/migrations/2026_04_10_064108_create_media_table.php
  9. 7 2
      packages/Longyi/Core/src/Contracts/ProductVariant.php
  10. 5 5
      packages/Longyi/Core/src/Database/Migrations/2026_03_07_000001_create_product_variant_images_table.php
  11. 25 0
      packages/Longyi/Core/src/Database/Migrations/2026_03_07_000002_add_meta_to_product_images_table.php
  12. 109 0
      packages/Longyi/Core/src/Database/Migrations/2026_04_16_000001_alter_product_variant_images_use_media_id.php
  13. 162 0
      packages/Longyi/Core/src/Helpers/ProductImage.php
  14. 114 48
      packages/Longyi/Core/src/Http/Controllers/Admin/FlexibleVariantController.php
  15. 109 0
      packages/Longyi/Core/src/Listeners/VariantInventoryListener.php
  16. 104 0
      packages/Longyi/Core/src/Models/ProductImage.php
  17. 69 44
      packages/Longyi/Core/src/Models/ProductVariant.php
  18. 35 1
      packages/Longyi/Core/src/Providers/LongyiCoreServiceProvider.php
  19. 128 0
      packages/Longyi/Core/src/Repositories/ProductImageRepository.php
  20. 1 1
      packages/Longyi/Core/src/Repositories/ProductVariantRepository.php
  21. 338 126
      packages/Longyi/Core/src/Resources/views/admin/catalog/products/edit/types/flexible_variant.blade.php
  22. 191 0
      packages/Longyi/Core/src/Resources/views/admin/catalog/products/edit/types/flexible_variant/mediaupload.blade copy.php
  23. 206 0
      packages/Longyi/Core/src/Resources/views/admin/catalog/products/edit/types/flexible_variant/mediaupload.blade.php
  24. 3 1
      packages/Longyi/Core/src/Routes/admin-routes.php
  25. 247 3
      packages/Longyi/Core/src/Type/FlexibleVariant.php
  26. 23 0
      packages/Longyi/Gift/package.json
  27. 6 0
      packages/Longyi/Gift/postcss.config.js
  28. 28 0
      packages/Longyi/Gift/src/Config/acl.php
  29. 11 0
      packages/Longyi/Gift/src/Config/admin-menu.php
  30. 7 0
      packages/Longyi/Gift/src/Contracts/GiftCardUsageLog.php
  31. 7 0
      packages/Longyi/Gift/src/Contracts/GiftCards.php
  32. 217 0
      packages/Longyi/Gift/src/DataGrids/GiftCards/GiftCardsDataGrid.php
  33. 36 0
      packages/Longyi/Gift/src/Database/Migrations/2026_04_01_184903_create_gift_cards_table.php
  34. 32 0
      packages/Longyi/Gift/src/Database/Migrations/2026_04_01_230815_add_giftcard_fields_to_cart_table.php
  35. 30 0
      packages/Longyi/Gift/src/Database/Migrations/2026_04_01_230829_add_giftcard_fields_to_orders_table.php
  36. 30 0
      packages/Longyi/Gift/src/Database/Migrations/2026_04_01_230841_add_giftcard_info_to_invoices_table.php
  37. 55 0
      packages/Longyi/Gift/src/Database/Migrations/2026_04_10_012025_create_gift_card_usage_logs_table.php
  38. 182 0
      packages/Longyi/Gift/src/Http/Controllers/Admin/GiftController.php
  39. 236 0
      packages/Longyi/Gift/src/Http/Controllers/Shop/GiftController.php
  40. 25 0
      packages/Longyi/Gift/src/Http/Resources/CustomCartResource.php
  41. 50 0
      packages/Longyi/Gift/src/Listeners/GiftHandler.php
  42. 33 0
      packages/Longyi/Gift/src/Listeners/InvoiceHandler.php
  43. 69 0
      packages/Longyi/Gift/src/Listeners/OrderCancelHandler.php
  44. 69 0
      packages/Longyi/Gift/src/Listeners/OrderPlacedHandler.php
  45. 32 0
      packages/Longyi/Gift/src/Listeners/OrderViewHandler.php
  46. 94 0
      packages/Longyi/Gift/src/Models/GiftCardUsageLog.php
  47. 9 0
      packages/Longyi/Gift/src/Models/GiftCardUsageLogProxy.php
  48. 135 0
      packages/Longyi/Gift/src/Models/GiftCards.php
  49. 9 0
      packages/Longyi/Gift/src/Models/GiftCardsProxy.php
  50. 26 0
      packages/Longyi/Gift/src/Providers/EventServiceProvider.php
  51. 64 0
      packages/Longyi/Gift/src/Providers/GiftServiceProvider.php
  52. 15 0
      packages/Longyi/Gift/src/Providers/ModuleServiceProvider.php
  53. 94 0
      packages/Longyi/Gift/src/Repositories/CustomInvoiceRepository.php
  54. 77 0
      packages/Longyi/Gift/src/Repositories/CustomOrderRepository.php
  55. 16 0
      packages/Longyi/Gift/src/Repositories/GiftCardsRepository.php
  56. 23 0
      packages/Longyi/Gift/src/Resources/assets/css/app.css
  57. 12 0
      packages/Longyi/Gift/src/Resources/assets/images/icon-temp-active.svg
  58. 12 0
      packages/Longyi/Gift/src/Resources/assets/images/icon-temp.svg
  59. 4 0
      packages/Longyi/Gift/src/Resources/assets/js/app.js
  60. 52 0
      packages/Longyi/Gift/src/Resources/lang/en/app.php
  61. 52 0
      packages/Longyi/Gift/src/Resources/lang/zh_CN/app.php
  62. 230 0
      packages/Longyi/Gift/src/Resources/views/admin/create.blade.php
  63. 25 0
      packages/Longyi/Gift/src/Resources/views/admin/index.blade.php
  64. 1 0
      packages/Longyi/Gift/src/Resources/views/admin/layouts/style.blade.php
  65. 309 0
      packages/Longyi/Gift/src/Resources/views/components/giftcard-cartsummary.blade.php
  66. 15 0
      packages/Longyi/Gift/src/Resources/views/customers/account/orders/giftcard-info.blade.php
  67. 533 0
      packages/Longyi/Gift/src/Resources/views/sales/invoices/view.blade.php
  68. 955 0
      packages/Longyi/Gift/src/Resources/views/sales/orders/view.blade.php
  69. 11 0
      packages/Longyi/Gift/src/Resources/views/shop/index.blade.php
  70. 18 0
      packages/Longyi/Gift/src/Routes/admin-routes.php
  71. 14 0
      packages/Longyi/Gift/src/Routes/shop-routes.php
  72. 4 0
      packages/Longyi/Gift/tailwind.config.js
  73. 46 0
      packages/Longyi/Gift/vite.config.js
  74. 23 0
      packages/Longyi/Member/package.json
  75. 6 0
      packages/Longyi/Member/postcss.config.js
  76. 10 0
      packages/Longyi/Member/src/Config/acl.php
  77. 11 0
      packages/Longyi/Member/src/Config/admin-menu.php
  78. 7 0
      packages/Longyi/Member/src/Contracts/MemberLog.php
  79. 107 0
      packages/Longyi/Member/src/DataGrids/Member/MemberDataGrid.php
  80. 28 0
      packages/Longyi/Member/src/Database/Migrations/2026_04_15_233520_add_vip_expire_fields_to_customer_table.php
  81. 31 0
      packages/Longyi/Member/src/Database/Migrations/2026_04_15_233955_add_member_fields_to_cart_table.php
  82. 31 0
      packages/Longyi/Member/src/Database/Migrations/2026_04_15_234016_add_member_fields_to_orders_table.php
  83. 31 0
      packages/Longyi/Member/src/Database/Migrations/2026_04_15_234035_add_member_fields_to_invoices_table.php
  84. 32 0
      packages/Longyi/Member/src/Database/Migrations/2026_04_20_180518_create_member_log_table.php
  85. 23 0
      packages/Longyi/Member/src/Http/Controllers/Admin/MemberController.php
  86. 17 0
      packages/Longyi/Member/src/Http/Controllers/Shop/MemberController.php
  87. 99 0
      packages/Longyi/Member/src/Http/Controllers/Shop/MemberDiscountController.php
  88. 47 0
      packages/Longyi/Member/src/Listeners/MemberHandler.php
  89. 63 0
      packages/Longyi/Member/src/Listeners/OrderPlacedHandler.php
  90. 28 0
      packages/Longyi/Member/src/Listeners/OrderViewHandler.php
  91. 98 0
      packages/Longyi/Member/src/Listeners/VipDiscountHandler.php
  92. 39 0
      packages/Longyi/Member/src/Models/MemberLog.php
  93. 9 0
      packages/Longyi/Member/src/Models/MemberLogProxy.php
  94. 33 0
      packages/Longyi/Member/src/Providers/EventServiceProvider.php
  95. 55 0
      packages/Longyi/Member/src/Providers/MemberServiceProvider.php
  96. 15 0
      packages/Longyi/Member/src/Providers/ModuleServiceProvider.php
  97. 16 0
      packages/Longyi/Member/src/Repositories/MemberLogRepository.php
  98. 23 0
      packages/Longyi/Member/src/Resources/assets/css/app.css
  99. 12 0
      packages/Longyi/Member/src/Resources/assets/images/icon-temp-active.svg
  100. 0 0
      packages/Longyi/Member/src/Resources/assets/images/icon-temp.svg

+ 1 - 0
.gitignore

@@ -33,3 +33,4 @@ yarn.lock
 yarn-error.log
 /test-results
 /playwright-report
+public/nginx.htaccess

+ 3 - 1
bootstrap/providers.php

@@ -13,8 +13,10 @@ return [
     Longyi\Core\Providers\LongyiCoreServiceProvider::class,
     Longyi\DynamicMenu\Providers\DynamicMenuServiceProvider::class,
     Longyi\Email\Providers\EmailServiceProvider::class,
-    Longyi\RewardPoints\Providers\RewardPointsServiceProvider::class,
     Longyi\ImageUpload\Providers\ImageUploadServiceProvider::class,
+    Longyi\RewardPoints\Providers\RewardPointsServiceProvider::class,
+    Longyi\Member\Providers\MemberServiceProvider::class,
+    Longyi\Gift\Providers\GiftServiceProvider::class,
     Webkul\Attribute\Providers\AttributeServiceProvider::class,
     Webkul\BookingProduct\Providers\BookingProductServiceProvider::class,
     Webkul\CMS\Providers\CMSServiceProvider::class,

+ 4 - 0
composer.json

@@ -20,6 +20,7 @@
         "api-platform/graphql": "v4.2.3",
         "api-platform/laravel": "v4.1.25",
         "astrotomic/laravel-translatable": "^11.0.0",
+        "bagisto/bagisto-package-generator": "^2.1",
         "bagisto/image-cache": "dev-master",
         "barryvdh/laravel-dompdf": "^2.0.0",
         "diglactic/laravel-breadcrumbs": "^9.0",
@@ -46,6 +47,7 @@
         "prettus/l5-repository": "^2.6",
         "pusher/pusher-php-server": "^7.0",
         "shetabit/visitor": "^4.1",
+        "spatie/laravel-medialibrary": "^11.21",
         "spatie/laravel-responsecache": "^7.4",
         "spatie/laravel-sitemap": "^7.3",
         "stevebauman/purify": "^6.3"
@@ -71,6 +73,8 @@
             "Longyi\\Email\\": "packages/Longyi/Email/src/",
             "Longyi\\ImageUpload\\": "packages/Longyi/ImageUpload/src/",
             "Longyi\\RewardPoints\\": "packages/Longyi/RewardPoints/src/",
+            "Longyi\\Member\\": "packages/Longyi/Member/src/",
+            "Longyi\\Gift\\": "packages/Longyi/Gift/src/",
             "Webkul\\Admin\\": "packages/Webkul/Admin/src",
             "Webkul\\Attribute\\": "packages/Webkul/Attribute/src",
             "Webkul\\BookingProduct\\": "packages/Webkul/BookingProduct/src",

+ 269 - 121
composer.lock

@@ -1161,145 +1161,55 @@
             "time": "2024-08-28T09:20:26+00:00"
         },
         {
-            "name": "aws/aws-crt-php",
-            "version": "v1.2.7",
+            "name": "bagisto/bagisto-package-generator",
+            "version": "v2.1.2",
             "source": {
                 "type": "git",
-                "url": "https://github.com/awslabs/aws-crt-php.git",
-                "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e"
+                "url": "https://github.com/bagisto/bagisto-package-generator.git",
+                "reference": "e6e9639c366f31f615160ff9c91bc78528aa8061"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://repo.huaweicloud.com/repository/php/aws/aws-crt-php/v1.2.7/aws-aws-crt-php-v1.2.7.zip",
-                "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=5.5"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^4.8.35||^5.6.3||^9.5",
-                "yoast/phpunit-polyfills": "^1.0"
-            },
-            "suggest": {
-                "ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality."
-            },
-            "type": "library",
-            "autoload": {
-                "classmap": [
-                    "src/"
+                "url": "https://api.github.com/repos/bagisto/bagisto-package-generator/zipball/e6e9639c366f31f615160ff9c91bc78528aa8061",
+                "reference": "e6e9639c366f31f615160ff9c91bc78528aa8061",
+                "shasum": "",
+                "mirrors": [
+                    {
+                        "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%",
+                        "preferred": true
+                    }
                 ]
             },
-            "license": [
-                "Apache-2.0"
-            ],
-            "authors": [
-                {
-                    "name": "AWS SDK Common Runtime Team",
-                    "email": "aws-sdk-common-runtime@amazon.com"
-                }
-            ],
-            "description": "AWS Common Runtime for PHP",
-            "homepage": "https://github.com/awslabs/aws-crt-php",
-            "keywords": [
-                "amazon",
-                "aws",
-                "crt",
-                "sdk"
-            ],
-            "time": "2024-10-18T22:15:13+00:00"
-        },
-        {
-            "name": "aws/aws-sdk-php",
-            "version": "3.339.4",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/aws/aws-sdk-php.git",
-                "reference": "ea62ad03645ef7a1d3f1cda2de49f0869de3c582"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://repo.huaweicloud.com/repository/php/aws/aws-sdk-php/3.339.4/aws-aws-sdk-php-3.339.4.zip",
-                "reference": "ea62ad03645ef7a1d3f1cda2de49f0869de3c582",
-                "shasum": ""
-            },
-            "require": {
-                "aws/aws-crt-php": "^1.2.3",
-                "ext-json": "*",
-                "ext-pcre": "*",
-                "ext-simplexml": "*",
-                "guzzlehttp/guzzle": "^7.4.5",
-                "guzzlehttp/promises": "^2.0",
-                "guzzlehttp/psr7": "^2.4.5",
-                "mtdowling/jmespath.php": "^2.8.0",
-                "php": ">=8.1",
-                "psr/http-message": "^2.0"
-            },
-            "require-dev": {
-                "andrewsville/php-token-reflection": "^1.4",
-                "aws/aws-php-sns-message-validator": "~1.0",
-                "behat/behat": "~3.0",
-                "composer/composer": "^2.7.8",
-                "dms/phpunit-arraysubset-asserts": "^0.4.0",
-                "doctrine/cache": "~1.4",
-                "ext-dom": "*",
-                "ext-openssl": "*",
-                "ext-pcntl": "*",
-                "ext-sockets": "*",
-                "paragonie/random_compat": ">= 2",
-                "phpunit/phpunit": "^5.6.3 || ^8.5 || ^9.5",
-                "psr/cache": "^2.0 || ^3.0",
-                "psr/simple-cache": "^2.0 || ^3.0",
-                "sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0",
-                "symfony/filesystem": "^v6.4.0 || ^v7.1.0",
-                "yoast/phpunit-polyfills": "^2.0"
-            },
-            "suggest": {
-                "aws/aws-php-sns-message-validator": "To validate incoming SNS notifications",
-                "doctrine/cache": "To use the DoctrineCacheAdapter",
-                "ext-curl": "To send requests using cURL",
-                "ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages",
-                "ext-sockets": "To use client-side monitoring"
-            },
             "type": "library",
             "extra": {
-                "branch-alias": {
-                    "dev-master": "3.0-dev"
+                "laravel": {
+                    "aliases": [],
+                    "providers": [
+                        "Webkul\\PackageGenerator\\Providers\\PackageGeneratorServiceProvider"
+                    ]
                 }
             },
             "autoload": {
-                "files": [
-                    "src/functions.php"
-                ],
                 "psr-4": {
-                    "Aws\\": "src/"
-                },
-                "exclude-from-classmap": [
-                    "src/data/"
-                ]
+                    "Webkul\\PackageGenerator\\": "src/"
+                }
             },
+            "notification-url": "https://packagist.org/downloads/",
             "license": [
-                "Apache-2.0"
+                "MIT"
             ],
             "authors": [
                 {
-                    "name": "Amazon Web Services",
-                    "homepage": "http://aws.amazon.com"
+                    "name": "Jitendra Singh",
+                    "email": "jitendra@webkul.com"
                 }
             ],
-            "description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project",
-            "homepage": "http://aws.amazon.com/sdkforphp",
-            "keywords": [
-                "amazon",
-                "aws",
-                "cloud",
-                "dynamodb",
-                "ec2",
-                "glacier",
-                "s3",
-                "sdk"
-            ],
-            "time": "2025-01-31T19:04:39+00:00"
+            "description": "Bagisto Package Generator",
+            "support": {
+                "issues": "https://github.com/bagisto/bagisto-package-generator/issues",
+                "source": "https://github.com/bagisto/bagisto-package-generator/tree/v2.1.2"
+            },
+            "time": "2025-09-02T16:15:30+00:00"
         },
         {
             "name": "bagisto/image-cache",
@@ -9043,6 +8953,244 @@
             ],
             "time": "2025-02-24T09:20:47+00:00"
         },
+        {
+            "name": "spatie/image",
+            "version": "3.9.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/spatie/image.git",
+                "reference": "6a322b5e9268e3903d4fb6e1ff08b7dcc3aa9429"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/spatie/image/zipball/6a322b5e9268e3903d4fb6e1ff08b7dcc3aa9429",
+                "reference": "6a322b5e9268e3903d4fb6e1ff08b7dcc3aa9429",
+                "shasum": ""
+            },
+            "require": {
+                "ext-exif": "*",
+                "ext-json": "*",
+                "ext-mbstring": "*",
+                "php": "^8.2",
+                "spatie/image-optimizer": "^1.7.5",
+                "spatie/temporary-directory": "^2.2",
+                "symfony/process": "^6.4|^7.0|^8.0"
+            },
+            "require-dev": {
+                "ext-gd": "*",
+                "ext-imagick": "*",
+                "laravel/sail": "^1.34",
+                "pestphp/pest": "^3.0|^4.0",
+                "phpstan/phpstan": "^1.10.50",
+                "spatie/pest-plugin-snapshots": "^2.1",
+                "spatie/pixelmatch-php": "^1.0",
+                "spatie/ray": "^1.40.1",
+                "symfony/var-dumper": "^6.4|^7.0|^8.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Spatie\\Image\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Freek Van der Herten",
+                    "email": "freek@spatie.be",
+                    "homepage": "https://spatie.be",
+                    "role": "Developer"
+                }
+            ],
+            "description": "Manipulate images with an expressive API",
+            "homepage": "https://github.com/spatie/image",
+            "keywords": [
+                "image",
+                "spatie"
+            ],
+            "support": {
+                "source": "https://github.com/spatie/image/tree/3.9.4"
+            },
+            "funding": [
+                {
+                    "url": "https://spatie.be/open-source/support-us",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/spatie",
+                    "type": "github"
+                }
+            ],
+            "time": "2026-03-13T14:23:45+00:00"
+        },
+        {
+            "name": "spatie/image-optimizer",
+            "version": "1.8.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/spatie/image-optimizer.git",
+                "reference": "2ad9ac7c19501739183359ae64ea6c15869c23d9"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/spatie/image-optimizer/zipball/2ad9ac7c19501739183359ae64ea6c15869c23d9",
+                "reference": "2ad9ac7c19501739183359ae64ea6c15869c23d9",
+                "shasum": ""
+            },
+            "require": {
+                "ext-fileinfo": "*",
+                "php": "^7.3|^8.0",
+                "psr/log": "^1.0 | ^2.0 | ^3.0",
+                "symfony/process": "^4.2|^5.0|^6.0|^7.0|^8.0"
+            },
+            "require-dev": {
+                "pestphp/pest": "^1.21|^2.0|^3.0|^4.0",
+                "phpunit/phpunit": "^8.5.21|^9.4.4|^10.0|^11.0|^12.0",
+                "symfony/var-dumper": "^4.2|^5.0|^6.0|^7.0|^8.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Spatie\\ImageOptimizer\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Freek Van der Herten",
+                    "email": "freek@spatie.be",
+                    "homepage": "https://spatie.be",
+                    "role": "Developer"
+                }
+            ],
+            "description": "Easily optimize images using PHP",
+            "homepage": "https://github.com/spatie/image-optimizer",
+            "keywords": [
+                "image-optimizer",
+                "spatie"
+            ],
+            "support": {
+                "issues": "https://github.com/spatie/image-optimizer/issues",
+                "source": "https://github.com/spatie/image-optimizer/tree/1.8.1"
+            },
+            "time": "2025-11-26T10:57:19+00:00"
+        },
+        {
+            "name": "spatie/laravel-medialibrary",
+            "version": "11.21.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/spatie/laravel-medialibrary.git",
+                "reference": "d6e2595033ffd130d4dd5d124510ab3304794c44"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/spatie/laravel-medialibrary/zipball/d6e2595033ffd130d4dd5d124510ab3304794c44",
+                "reference": "d6e2595033ffd130d4dd5d124510ab3304794c44",
+                "shasum": ""
+            },
+            "require": {
+                "composer/semver": "^3.4",
+                "ext-exif": "*",
+                "ext-fileinfo": "*",
+                "ext-json": "*",
+                "illuminate/bus": "^10.2|^11.0|^12.0|^13.0",
+                "illuminate/conditionable": "^10.2|^11.0|^12.0|^13.0",
+                "illuminate/console": "^10.2|^11.0|^12.0|^13.0",
+                "illuminate/database": "^10.2|^11.0|^12.0|^13.0",
+                "illuminate/pipeline": "^10.2|^11.0|^12.0|^13.0",
+                "illuminate/support": "^10.2|^11.0|^12.0|^13.0",
+                "maennchen/zipstream-php": "^3.1",
+                "php": "^8.2",
+                "spatie/image": "^3.3.2",
+                "spatie/laravel-package-tools": "^1.16.1",
+                "spatie/temporary-directory": "^2.2",
+                "symfony/console": "^6.4.1|^7.0|^8.0"
+            },
+            "conflict": {
+                "php-ffmpeg/php-ffmpeg": "<0.6.1"
+            },
+            "require-dev": {
+                "aws/aws-sdk-php": "^3.293.10",
+                "ext-imagick": "*",
+                "ext-pdo_sqlite": "*",
+                "ext-zip": "*",
+                "guzzlehttp/guzzle": "^7.8.1",
+                "larastan/larastan": "^2.7|^3.0",
+                "league/flysystem-aws-s3-v3": "^3.22",
+                "mockery/mockery": "^1.6.7",
+                "orchestra/testbench": "^8.36|^9.15|^10.8|^11.0",
+                "pestphp/pest": "^2.36|^3.0|^4.0",
+                "phpstan/extension-installer": "^1.3.1",
+                "spatie/laravel-ray": "^1.33",
+                "spatie/pdf-to-image": "^2.2|^3.0",
+                "spatie/pest-expectations": "^1.13",
+                "spatie/pest-plugin-snapshots": "^2.1"
+            },
+            "suggest": {
+                "league/flysystem-aws-s3-v3": "Required to use AWS S3 file storage",
+                "php-ffmpeg/php-ffmpeg": "Required for generating video thumbnails",
+                "spatie/pdf-to-image": "Required for generating thumbnails of PDFs and SVGs"
+            },
+            "type": "library",
+            "extra": {
+                "laravel": {
+                    "providers": [
+                        "Spatie\\MediaLibrary\\MediaLibraryServiceProvider"
+                    ]
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Spatie\\MediaLibrary\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Freek Van der Herten",
+                    "email": "freek@spatie.be",
+                    "homepage": "https://spatie.be",
+                    "role": "Developer"
+                }
+            ],
+            "description": "Associate files with Eloquent models",
+            "homepage": "https://github.com/spatie/laravel-medialibrary",
+            "keywords": [
+                "cms",
+                "conversion",
+                "downloads",
+                "images",
+                "laravel",
+                "laravel-medialibrary",
+                "media",
+                "spatie"
+            ],
+            "support": {
+                "issues": "https://github.com/spatie/laravel-medialibrary/issues",
+                "source": "https://github.com/spatie/laravel-medialibrary/tree/11.21.0"
+            },
+            "funding": [
+                {
+                    "url": "https://spatie.be/open-source/support-us",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/spatie",
+                    "type": "github"
+                }
+            ],
+            "time": "2026-02-21T15:58:56+00:00"
+        },
         {
             "name": "spatie/laravel-package-tools",
             "version": "1.19.0",
@@ -16215,6 +16363,6 @@
         "ext-pdo_mysql": "*",
         "ext-tokenizer": "*"
     },
-    "platform-dev": {},
-    "plugin-api-version": "2.9.0"
+    "platform-dev": [],
+    "plugin-api-version": "2.6.0"
 }

+ 7 - 1
config/logging.php

@@ -64,7 +64,13 @@ return [
             'level'                => env('LOG_LEVEL', 'debug'),
             'replace_placeholders' => true,
         ],
-
+        'giftcard' => [
+            'driver'               => 'daily',
+            'path'                 => storage_path('logs/giftcard.log'),
+            'level'                => 'info',
+            'days'                 => 14,
+            'replace_placeholders' => true,
+        ],
         'daily' => [
             'driver'               => 'daily',
             'path'                 => storage_path('logs/laravel.log'),

+ 234 - 0
config/media-library.php

@@ -0,0 +1,234 @@
+<?php
+
+use Spatie\MediaLibrary\Conversions\ImageGenerators\Image;
+use Spatie\MediaLibrary\Conversions\ImageGenerators\Pdf;
+use Spatie\MediaLibrary\Conversions\ImageGenerators\Svg;
+use Spatie\MediaLibrary\Conversions\ImageGenerators\Video;
+use Spatie\MediaLibrary\Conversions\ImageGenerators\Webp;
+use Spatie\MediaLibrary\Conversions\ImageGenerators\Avif;
+use Spatie\MediaLibrary\Conversions\Jobs\PerformConversionsJob;
+use Spatie\MediaLibrary\Downloaders\DefaultDownloader;
+use Spatie\MediaLibrary\MediaCollections\FileAdder;
+use Spatie\MediaLibrary\MediaCollections\Models\Media;
+use Spatie\MediaLibrary\MediaCollections\Models\Observers\MediaObserver;
+use Spatie\MediaLibrary\ResponsiveImages\Jobs\GenerateResponsiveImagesJob;
+use Spatie\MediaLibrary\ResponsiveImages\TinyPlaceholderGenerator\Blurred;
+use Spatie\MediaLibrary\ResponsiveImages\WidthCalculator\FileSizeOptimizedWidthCalculator;
+use Spatie\MediaLibrary\Support\FileNamer\DefaultFileNamer;
+use Spatie\MediaLibrary\Support\FileRemover\DefaultFileRemover;
+use Spatie\MediaLibrary\Support\PathGenerator\DefaultPathGenerator;
+use Spatie\MediaLibrary\Support\UrlGenerator\DefaultUrlGenerator;
+use Spatie\MediaLibraryPro\Models\TemporaryUpload;
+
+return [
+
+    /*
+     * The disk on which to store added files and derived images by default.
+     */
+    'disk_name' => env('MEDIA_DISK', 's3'),
+
+    /*
+     * The disk for conversions when not explicitly specified.
+     */
+    'conversions_disk_name' => env('MEDIA_CONVERSIONS_DISK', null),
+
+    /*
+     * The maximum file size of an item in bytes.
+     */
+    'max_file_size' => 1024 * 1024 * 10,
+
+    /*
+     * Disallowed file extensions.
+     */
+    'disallowed_extensions' => FileAdder::$defaultDisallowedExtensions,
+
+    /*
+     * Allowed file extensions filter.
+     */
+    'allowed_extensions' => null,
+
+    /*
+     * Queue connection for generating derived images.
+     */
+    'queue_connection_name' => env('QUEUE_CONNECTION', 'sync'),
+
+    /*
+     * Queue name for generating derived images.
+     */
+    'queue_name' => env('MEDIA_QUEUE', ''),
+
+    /*
+     * Queue conversions by default.
+     */
+    'queue_conversions_by_default' => env('QUEUE_CONVERSIONS_BY_DEFAULT', true),
+
+    /*
+     * Should DB transactions be run after DB commits.
+     */
+    'queue_conversions_after_database_commit' => env('QUEUE_CONVERSIONS_AFTER_DB_COMMIT', true),
+
+    /*
+     * The fully qualified class name of the media model.
+     */
+    'media_model' => Media::class,
+
+    /*
+     * The fully qualified class name of the media observer.
+     */
+    'media_observer' => MediaObserver::class,
+
+    /*
+     * Media collection serialization.
+     */
+    'use_default_collection_serialization' => false,
+
+    /*
+     * Temporary upload model (Media Library Pro).
+     */
+    'temporary_upload_model' => TemporaryUpload::class,
+
+    /*
+     * Enable temporary uploads session affinity.
+     */
+    'enable_temporary_uploads_session_affinity' => true,
+
+    /*
+     * Generate thumbnails for temporary uploads.
+     */
+    'generate_thumbnails_for_temporary_uploads' => true,
+
+    /*
+     * File namer class.
+     */
+    'file_namer' => DefaultFileNamer::class,
+
+    /*
+     * Path generator class.
+     */
+    'path_generator' => DefaultPathGenerator::class,
+
+    /*
+     * File remover class.
+     */
+    'file_remover_class' => DefaultFileRemover::class,
+
+    /*
+     * Custom path generators per model.
+     */
+    'custom_path_generators' => [],
+
+    /*
+     * URL generator class.
+     */
+    'url_generator' => DefaultUrlGenerator::class,
+
+    /*
+     * Moves media on update to keep path consistent.
+     */
+    'moves_media_on_update' => false,
+
+    /*
+     * Version urls with ?v=xx query string.
+     */
+    'version_urls' => false,
+
+    /*
+     * Image optimizers.
+     */
+    'image_optimizers' => [
+        // Left empty - optimization handled by project's ImageUpload service
+    ],
+
+    /*
+     * Image generators.
+     */
+    'image_generators' => [
+        Image::class,
+        Webp::class,
+        Avif::class,
+        Pdf::class,
+        Svg::class,
+        Video::class,
+    ],
+
+    /*
+     * Temporary directory path for image conversions.
+     */
+    'temporary_directory_path' => null,
+
+    /*
+     * Image driver: gd, imagick or vips.
+     */
+    'image_driver' => env('IMAGE_DRIVER', 'gd'),
+
+    /*
+     * FFMPEG paths.
+     */
+    'ffmpeg_path' => env('FFMPEG_PATH', '/usr/bin/ffmpeg'),
+    'ffprobe_path' => env('FFPROBE_PATH', '/usr/bin/ffprobe'),
+
+    /*
+     * FFMPEG timeout in seconds.
+     */
+    'ffmpeg_timeout' => env('FFMPEG_TIMEOUT', 900),
+
+    /*
+     * FFMPEG threads.
+     */
+    'ffmpeg_threads' => env('FFMPEG_THREADS', 0),
+
+    /*
+     * Jobs used by the package.
+     */
+    'jobs' => [
+        'perform_conversions' => PerformConversionsJob::class,
+        'generate_responsive_images' => GenerateResponsiveImagesJob::class,
+    ],
+
+    /*
+     * Media downloader used by addMediaFromUrl.
+     */
+    'media_downloader' => DefaultDownloader::class,
+
+    /*
+     * SSL verification for remote media downloads.
+     */
+    'media_downloader_ssl' => env('MEDIA_DOWNLOADER_SSL', true),
+
+    /*
+     * Default lifetime in minutes for temporary URLs.
+     */
+    'temporary_url_default_lifetime' => env('MEDIA_TEMPORARY_URL_DEFAULT_LIFETIME', 5),
+
+    'remote' => [
+        'extra_headers' => [
+            'CacheControl' => 'max-age=604800',
+        ],
+    ],
+
+    'responsive_images' => [
+        'width_calculator' => FileSizeOptimizedWidthCalculator::class,
+        'use_tiny_placeholders' => true,
+        'tiny_placeholder_generator' => Blurred::class,
+    ],
+
+    /*
+     * Enable Vapor uploads for Media Library Pro.
+     */
+    'enable_vapor_uploads' => env('ENABLE_MEDIA_LIBRARY_VAPOR_UPLOADS', false),
+
+    /*
+     * Default loading attribute for img tags.
+     */
+    'default_loading_attribute_value' => null,
+
+    /*
+     * Storage prefix for media.
+     */
+    'prefix' => env('MEDIA_PREFIX', ''),
+
+    /*
+     * Force lazy loading.
+     */
+    'force_lazy_loading' => env('FORCE_MEDIA_LIBRARY_LAZY_LOADING', true),
+];

+ 19 - 0
config/visitor.php

@@ -34,4 +34,23 @@ return [
         'jenssegers' => \Shetabit\Visitor\Drivers\JenssegersAgent::class,
         'UAParser'   => \Shetabit\Visitor\Drivers\UAParser::class,
     ],
+
+    /*
+    |--------------------------------------------------------------------------
+    | GeoIP Enrichment
+    |--------------------------------------------------------------------------
+    |
+    | When enabled, visits can be enriched with geolocation data stored
+    | in the "geo_raw" JSON column. This package ships with a default
+    | resolver (stevebauman/location). You may implement your own by
+    | adding it to the drivers array below. Each driver must implement
+    | Shetabit\Visitor\Contracts\GeoIpResolver.
+    |
+    */
+    'geoip'    => false,            // disable enrichment by default
+    'resolver' => 'stevebauman',    // default resolver
+    'resolvers' => [
+        'stevebauman' => \Shetabit\Visitor\Resolvers\GeoIp\SteveBaumanResolver::class,
+        'null'        => \Shetabit\Visitor\Resolvers\GeoIp\NullResolver::class,
+    ],
 ];

+ 32 - 0
database/migrations/2026_04_10_064108_create_media_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('media', function (Blueprint $table) {
+            $table->id();
+
+            $table->morphs('model');
+            $table->uuid()->nullable()->unique();
+            $table->string('collection_name');
+            $table->string('name');
+            $table->string('file_name');
+            $table->string('mime_type')->nullable();
+            $table->string('disk');
+            $table->string('conversions_disk')->nullable();
+            $table->unsignedBigInteger('size');
+            $table->json('manipulations');
+            $table->json('custom_properties');
+            $table->json('generated_conversions');
+            $table->json('responsive_images');
+            $table->unsignedInteger('order_column')->nullable()->index();
+
+            $table->nullableTimestamps();
+        });
+    }
+};

+ 7 - 2
packages/Longyi/Core/src/Contracts/ProductVariant.php

@@ -25,7 +25,12 @@ interface ProductVariant
     public function getEffectivePrice(?int $customerGroupId = null, ?int $channelId = null): float;
 
     /**
-     * Get images for this variant (many-to-many with product_images).
+     * Pivot relation to Media entries via product_variant_images.
      */
-    public function images();
+    public function variantImages();
+
+    /**
+     * Get all images (formatted) for this variant.
+     */
+    public function getAllImages(): \Illuminate\Support\Collection;
 }

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

@@ -11,7 +11,7 @@ return new class extends Migration
         Schema::create('product_variant_images', function (Blueprint $table) {
             $table->increments('id');
             $table->unsignedInteger('product_variant_id');
-            $table->unsignedInteger('product_image_id');
+            $table->unsignedBigInteger('media_id');
             $table->integer('position')->default(0);
 
             $table->foreign('product_variant_id')
@@ -19,13 +19,13 @@ return new class extends Migration
                 ->on('product_variants')
                 ->onDelete('cascade');
 
-            $table->foreign('product_image_id')
+            $table->foreign('media_id')
                 ->references('id')
-                ->on('product_images')
+                ->on('media')
                 ->onDelete('cascade');
 
-            $table->unique(['product_variant_id', 'product_image_id'], 'variant_image_unique');
-            $table->index('product_image_id', 'idx_image_id');
+            $table->unique(['product_variant_id', 'media_id'], 'variant_media_unique');
+            $table->index('media_id', 'idx_media_id');
         });
     }
 

+ 25 - 0
packages/Longyi/Core/src/Database/Migrations/2026_03_07_000002_add_meta_to_product_images_table.php

@@ -0,0 +1,25 @@
+<?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::table('product_images', function (Blueprint $table) {
+            $table->string('label')->nullable()->after('position')->comment('图片 alt / label 文本');
+            $table->boolean('is_base_image')->default(false)->after('label')->comment('是否为主图');
+            $table->boolean('is_small_image')->default(false)->after('is_base_image')->comment('是否为小图');
+            $table->boolean('is_thumbnail')->default(false)->after('is_small_image')->comment('是否为缩略图');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('product_images', function (Blueprint $table) {
+            $table->dropColumn(['label', 'is_base_image', 'is_small_image', 'is_thumbnail']);
+        });
+    }
+};

+ 109 - 0
packages/Longyi/Core/src/Database/Migrations/2026_04_16_000001_alter_product_variant_images_use_media_id.php

@@ -0,0 +1,109 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        // ── Step 1: release any FKs / indexes that may still exist ───────────
+        Schema::table('product_variant_images', function (Blueprint $table) {
+            $existing = $this->existingConstraints();
+
+            $tablePrefix = DB::getTablePrefix();
+            if (in_array( $tablePrefix.'product_variant_images_product_image_id_foreign', $existing['fks'])) {
+                $table->dropForeign( $tablePrefix.'product_variant_images_product_image_id_foreign');
+            }
+            if (in_array( $tablePrefix.'product_variant_images_product_variant_id_foreign', $existing['fks'])) {
+                $table->dropForeign( $tablePrefix.'product_variant_images_product_variant_id_foreign');
+            }
+            if (in_array( $tablePrefix.'variant_image_unique', $existing['indexes'])) {
+                $table->dropUnique( $tablePrefix.'variant_image_unique');
+            }
+            if (in_array( $tablePrefix.'idx_image_id', $existing['indexes'])) {
+                $table->dropIndex( $tablePrefix.'idx_image_id');
+            }
+        });
+
+        // ── Step 2: rename column if it hasn't been renamed yet ──────────────
+        if (Schema::hasColumn('product_variant_images', 'product_image_id')) {
+            Schema::table('product_variant_images', function (Blueprint $table) {
+                $table->renameColumn('product_image_id', 'media_id');
+            });
+        }
+
+        // ── Step 3: ensure media_id is bigint unsigned to match media.id ─────
+        $tableName = DB::getTablePrefix() . 'product_variant_images';
+        DB::statement("ALTER TABLE `{$tableName}` MODIFY COLUMN media_id BIGINT UNSIGNED NOT NULL");
+
+        // ── Step 4: restore constraints ──────────────────────────────────────
+        Schema::table('product_variant_images', function (Blueprint $table) {
+            $table->foreign('product_variant_id')
+                ->references('id')
+                ->on('product_variants')
+                ->onDelete('cascade');
+
+            $table->foreign('media_id')
+                ->references('id')
+                ->on('media')
+                ->onDelete('cascade');
+
+            $table->unique(['product_variant_id', 'media_id'], 'variant_media_unique');
+            $table->index('media_id', 'idx_media_id');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('product_variant_images', function (Blueprint $table) {
+            $table->dropForeign(['media_id']);
+            $table->dropForeign(['product_variant_id']);
+            $table->dropUnique('variant_media_unique');
+            $table->dropIndex('idx_media_id');
+        });
+
+        $tableName = DB::getTablePrefix() . 'product_variant_images';
+        DB::statement("ALTER TABLE `{$tableName}` MODIFY COLUMN media_id INT UNSIGNED NOT NULL");
+
+        Schema::table('product_variant_images', function (Blueprint $table) {
+            $table->renameColumn('media_id', 'product_image_id');
+        });
+
+        Schema::table('product_variant_images', function (Blueprint $table) {
+            $table->foreign('product_variant_id')
+                ->references('id')
+                ->on('product_variants')
+                ->onDelete('cascade');
+
+            $table->unique(['product_variant_id', 'product_image_id'], 'variant_image_unique');
+            $table->index('product_image_id', 'idx_image_id');
+        });
+    }
+
+    private function existingConstraints(): array
+    {
+        $tablePrefix = DB::getTablePrefix();
+        $fks = DB::select("
+            SELECT CONSTRAINT_NAME
+            FROM information_schema.TABLE_CONSTRAINTS
+            WHERE TABLE_NAME = '{$tablePrefix}product_variant_images'
+              AND CONSTRAINT_SCHEMA = DATABASE()
+              AND CONSTRAINT_TYPE = 'FOREIGN KEY'
+        ");
+
+        $indexes = DB::select("
+            SELECT DISTINCT INDEX_NAME
+            FROM information_schema.STATISTICS
+            WHERE TABLE_NAME = '{$tablePrefix}product_variant_images'
+              AND TABLE_SCHEMA = DATABASE()
+        ");
+
+        return [
+            'fks'     => array_column($fks, 'CONSTRAINT_NAME'),
+            'indexes' => array_column($indexes, 'INDEX_NAME'),
+        ];
+    }
+};

+ 162 - 0
packages/Longyi/Core/src/Helpers/ProductImage.php

@@ -0,0 +1,162 @@
+<?php
+
+namespace Longyi\Core\Helpers;
+
+use Webkul\Product\ProductImage as BaseProductImage;
+
+/**
+ * Extended ProductImage helper.
+ *
+ * Adds support for role-based image retrieval:
+ *   - getBaseImage($product)      → image with is_base_image = true (falls back to first)
+ *   - getSmallImage($product)     → image with is_small_image = true (falls back to first)
+ *   - getThumbnailImage($product) → image with is_thumbnail = true  (falls back to first)
+ *
+ * Also overrides getProductBaseImage() to honour the is_base_image flag.
+ */
+class ProductImage extends BaseProductImage
+{
+    /**
+     * Get the image designated as the base image.
+     * Falls back to the first image if none is explicitly set.
+     *
+     * @param  \Webkul\Product\Contracts\Product  $product
+     * @return array|null
+     */
+    public function getBaseImage($product): ?array
+    {
+        if (! $product) {
+            return null;
+        }
+
+        $image = $product->images->first(fn ($img) => $img->is_base_image)
+            ?? $product->images->first();
+
+        return $image ? $this->getCachedImageUrlsPublic($image->path) : null;
+    }
+
+    /**
+     * Get the image designated as the small image.
+     * Falls back to the first image if none is explicitly set.
+     *
+     * @param  \Webkul\Product\Contracts\Product  $product
+     * @return array|null
+     */
+    public function getSmallImage($product): ?array
+    {
+        if (! $product) {
+            return null;
+        }
+
+        $image = $product->images->first(fn ($img) => $img->is_small_image)
+            ?? $product->images->first();
+
+        return $image ? $this->getCachedImageUrlsPublic($image->path) : null;
+    }
+
+    /**
+     * Get the image designated as the thumbnail.
+     * Falls back to the first image if none is explicitly set.
+     *
+     * @param  \Webkul\Product\Contracts\Product  $product
+     * @return array|null
+     */
+    public function getThumbnailImage($product): ?array
+    {
+        if (! $product) {
+            return null;
+        }
+
+        $image = $product->images->first(fn ($img) => $img->is_thumbnail)
+            ?? $product->images->first();
+
+        return $image ? $this->getCachedImageUrlsPublic($image->path) : null;
+    }
+
+    /**
+     * Override: honour is_base_image flag when loading the "base" product image.
+     *
+     * @param  \Webkul\Product\Contracts\Product  $product
+     * @param  array|null  $galleryImages
+     * @return array|null
+     */
+    public function getProductBaseImage($product, ?array $galleryImages = null)
+    {
+        if (! $product) {
+            return null;
+        }
+
+        // If gallery images are already resolved externally, use the first one (original behaviour)
+        if ($galleryImages) {
+            return $galleryImages[0];
+        }
+
+        return $this->loadBaseImageFromProduct($product);
+    }
+
+    /**
+     * Load the base image from the product, preferring is_base_image = true.
+     */
+    protected function loadBaseImageFromProduct($product): ?array
+    {
+        $images = $product?->images;
+
+        if (! $images || $images->isEmpty()) {
+            return $this->getFallbackImageUrlsPublic();
+        }
+
+        $baseImage = $images->first(fn ($img) => $img->is_base_image) ?? $images->first();
+
+        return $this->getCachedImageUrlsPublic($baseImage->path);
+    }
+
+    /**
+     * Expose the private getCachedImageUrls() from the parent via reflection
+     * by duplicating the logic here (parent method is private, not accessible).
+     */
+    protected function getCachedImageUrlsPublic(string $path): array
+    {
+        if (! $this->isDriverLocalPublic()) {
+            return [
+                'small_image_url'    => \Illuminate\Support\Facades\Storage::url($path),
+                'medium_image_url'   => \Illuminate\Support\Facades\Storage::url($path),
+                'large_image_url'    => \Illuminate\Support\Facades\Storage::url($path),
+                'original_image_url' => \Illuminate\Support\Facades\Storage::url($path),
+            ];
+        }
+
+        return [
+            'small_image_url'    => url('cache/small/'.$path),
+            'medium_image_url'   => url('cache/medium/'.$path),
+            'large_image_url'    => url('cache/large/'.$path),
+            'original_image_url' => url('cache/original/'.$path),
+        ];
+    }
+
+    protected function getFallbackImageUrlsPublic(): array
+    {
+        $smallImageUrl = core()->getConfigData('catalog.products.cache_small_image.url')
+            ? \Illuminate\Support\Facades\Storage::url(core()->getConfigData('catalog.products.cache_small_image.url'))
+            : bagisto_asset('images/small-product-placeholder.webp', 'shop');
+
+        $mediumImageUrl = core()->getConfigData('catalog.products.cache_medium_image.url')
+            ? \Illuminate\Support\Facades\Storage::url(core()->getConfigData('catalog.products.cache_medium_image.url'))
+            : bagisto_asset('images/medium-product-placeholder.webp', 'shop');
+
+        $largeImageUrl = core()->getConfigData('catalog.products.cache_large_image.url')
+            ? \Illuminate\Support\Facades\Storage::url(core()->getConfigData('catalog.products.cache_large_image.url'))
+            : bagisto_asset('images/large-product-placeholder.webp', 'shop');
+
+        return [
+            'small_image_url'    => $smallImageUrl,
+            'medium_image_url'   => $mediumImageUrl,
+            'large_image_url'    => $largeImageUrl,
+            'original_image_url' => bagisto_asset('images/large-product-placeholder.webp', 'shop'),
+        ];
+    }
+
+    protected function isDriverLocalPublic(): bool
+    {
+        return \Illuminate\Support\Facades\Storage::getAdapter() instanceof \League\Flysystem\Local\LocalFilesystemAdapter;
+    }
+}

+ 114 - 48
packages/Longyi/Core/src/Http/Controllers/Admin/FlexibleVariantController.php

@@ -4,13 +4,10 @@ namespace Longyi\Core\Http\Controllers\Admin;
 
 use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
-use Illuminate\Http\UploadedFile;
 use Illuminate\Routing\Controller;
+use Illuminate\Support\Collection;
 use Illuminate\Foundation\Validation\ValidatesRequests;
 use Illuminate\Support\Facades\DB;
-use Illuminate\Support\Facades\Storage;
-use Illuminate\Support\Str;
-use Intervention\Image\ImageManager;
 use Longyi\Core\Helpers\FlexibleVariantOption;
 use Longyi\Core\Models\ProductOption;
 use Longyi\Core\Models\ProductOptionValue;
@@ -18,6 +15,7 @@ use Longyi\Core\Models\ProductVariant;
 use Longyi\Core\Repositories\ProductOptionRepository;
 use Longyi\Core\Repositories\ProductOptionValueRepository;
 use Longyi\Core\Repositories\ProductVariantRepository;
+use Spatie\MediaLibrary\MediaCollections\Models\Media;
 use Webkul\Product\Helpers\Indexers\Price as PriceIndexer;
 use Webkul\Product\Repositories\ProductRepository;
 
@@ -351,7 +349,7 @@ class FlexibleVariantController extends Controller
             'variants.*.values' => 'array',
             'variants.*.values.*.code' => 'required|string',
             'variants.*.image_ids' => 'array',
-            'variants.*.image_ids.*' => 'integer|exists:product_images,id',
+            'variants.*.image_ids.*' => 'integer|exists:media,id',
         ]);
 
 
@@ -420,8 +418,8 @@ class FlexibleVariantController extends Controller
             if (isset($variantData['image_ids'])) {
                 $imageSync = collect($variantData['image_ids'])
                     ->values()
-                    ->mapWithKeys(fn ($imageId, $i) => [$imageId => ['position' => $i]]);
-                $variant->images()->sync($imageSync);
+                    ->mapWithKeys(fn ($mediaId, $i) => [$mediaId => ['position' => $i]]);
+                $variant->variantImages()->sync($imageSync);
             }
 
             $variants[$variantIndex]['variant_id'] = $variant->id;
@@ -442,13 +440,15 @@ class FlexibleVariantController extends Controller
         //删除无用的变体
         $this->_product->flexibleVariants()->whereNotIn('id', $variantIds)->delete();
         DB::commit();
+        
+        
 
         $this->reindexProduct($productId);
         return response()->json([
             'success' => true,
             'message' => 'Variants saved successfully',
             'data' => [
-                'variants' => $variants,
+                'variants' =>  $variants = $this->flexibleVariantHelper->getProductVariants($productId),
                 'selected_options' => $this->_selectedOptions,
             ],
         ]);
@@ -660,71 +660,137 @@ class FlexibleVariantController extends Controller
     /**
      * Sync images for a variant.
      *
-     * Accepts a mix of existing product_image IDs and new file uploads.
-     * New files are stored into the product gallery first, then all IDs
-     * are synced to the variant via the pivot table.
-     *
      * POST /variants/{id}/images
      * Body (multipart/form-data):
-     *   image_ids[]   - existing product_image IDs to keep/attach
-     *   uploads[]     - new image files to upload
+     *   media_ids[]  - existing Media IDs (spatie/medialibrary) to attach/keep
+     *   uploads[]    - new image files to upload (stored via medialibrary on the variant)
+     *
+     * Workflow:
+     *   1. New uploads are added to the variant's 'variant_images' media collection.
+     *   2. All resulting Media IDs (new + existing) are synced into
+     *      the product_variant_images pivot table with position order.
      */
-    public function syncVariantImages(int $id, Request $request): JsonResponse
+    public function syncVariantImages(Request $request): JsonResponse
     {
         $this->validate($request, [
-            'image_ids'   => 'array',
-            'image_ids.*' => 'integer|exists:product_images,id',
-            'uploads'     => 'array',
-            'uploads.*'   => 'image|max:5120',
+            'variant_ids'   => 'required|array',
+            'variant_ids.*' => 'required|exists:product_variants,id',
+            'file'          => 'sometimes|image|max:5120',
+            'media_ids'     => 'sometimes|array',
+            'media_ids.*'   => 'integer|exists:media,id',
         ]);
 
-        $variant = ProductVariant::findOrFail($id);
-        $product = $variant->product;
+        $variants = ProductVariant::findMany($request->input('variant_ids'));
+
+        $uploadedMedia = null;
 
-        $imageIds = collect($request->input('image_ids', []));
+        if ($request->hasFile('file')) {
+            $uploadedMedia = $variants->first()
+                ->addMedia($request->file('file'))
+                ->toMediaCollection(ProductVariant::IMAGES_COLLECTION);
 
-        if ($request->hasFile('uploads')) {
-            foreach ($request->file('uploads') as $file) {
-                $newImage = $this->storeProductImage($file, $product);
-                $imageIds->push($newImage->id);
+            foreach ($variants as $variant) {
+                $position = $variant->variantImages()->count();
+                $variant->variantImages()->attach($uploadedMedia->id, ['position' => $position]);
             }
         }
 
-        $syncData = $imageIds->values()->mapWithKeys(
-            fn ($imageId, $i) => [$imageId => ['position' => $i]]
-        );
-
-        $variant->images()->sync($syncData);
+        // Only sync explicit media_ids when the caller provides them (e.g. for reordering).
+        // Never sync an empty array implicitly — that would wipe all variant images.
+        if ($request->has('media_ids')) {
+            $mediaIds = collect($request->input('media_ids'));
+            $variants->each(fn ($variant) => $variant->variantImages()->sync($mediaIds->toArray()));
+        }
 
         return response()->json([
             'success' => true,
-            'data'    => $variant->load('images')->images,
+            'data'    => $uploadedMedia ? $this->formatMediaItem($uploadedMedia) : null,
+        ], 201);
+    }
+
+    /**
+     * Upload a new image for a variant without replacing existing ones.
+     *
+     * POST /variants/{id}/images/upload
+     */
+    public function uploadVariantImage(int $id, Request $request): JsonResponse
+    {
+        $this->validate($request, [
+            'file' => 'required|image|max:5120',
         ]);
+
+        $variant = ProductVariant::findOrFail($id);
+
+        $media = $variant
+            ->addMedia($request->file('file'))
+            ->toMediaCollection(ProductVariant::IMAGES_COLLECTION);
+
+        // Append to pivot table at the end
+        $nextPosition = $variant->variantImages()->count();
+        $variant->variantImages()->attach($media->id, ['position' => $nextPosition]);
+
+        return response()->json([
+            'success' => true,
+            'data'    => $this->formatMediaItem($media),
+        ], 201);
     }
 
     /**
-     * Store an uploaded file into the product gallery (product_images table).
+     * Remove a single media item from a variant (detach from pivot + delete media record).
+     *
+     * DELETE /variants/{id}/images/{mediaId}
      */
-    protected function storeProductImage(UploadedFile $file, $product): \Webkul\Product\Models\ProductImage
+    public function removeVariantImage(int $mediaId, Request $request): JsonResponse
     {
-        $directory = 'product/' . $product->id;
-
-        if (Str::contains($file->getMimeType(), 'image')) {
-            $manager = new ImageManager;
-            $image = $manager->make($file)->encode('webp');
-            $path = $directory . '/' . Str::random(40) . '.webp';
-            Storage::put($path, $image);
-        } else {
-            $path = $file->store($directory);
-        }
+        $this->validate($request, [
+            'variant_ids'   => 'required|array',
+            'variant_ids.*' => 'required|exists:product_variants,id'
+        ]);
+
 
-        return $product->images()->create([
-            'type'     => 'images',
-            'path'     => $path,
-            'position' => $product->images()->count(),
+        $variants = ProductVariant::findMany($request->input('variant_ids'));
+
+        $variants->each(fn ($variant) => $variant->variantImages()->detach($request->input('media_id')));
+
+
+        // Only delete the media record if no other variant still references it
+        $stillReferenced = \DB::table('product_variant_images')
+            ->where('media_id', $mediaId)
+            ->exists();
+
+        if (!$stillReferenced) {
+            Media::findOrFail($mediaId)->delete();
+        }
+        return response()->json([
+            'success' => true,
+            'message' => 'Image removed successfully',
         ]);
     }
 
+    /**
+     * Format a variant's images for API response.
+     */
+    protected function formatVariantImages($variants): Collection
+    {
+        return $variants->map(fn ($variant) => $variant->variantImages()->orderBy('position')->get()->map(fn ($media) => $this->formatMediaItem($media))->all());
+    }
+
+    /**
+     * Format a single Media item for API response.
+     */
+    protected function formatMediaItem(Media $media): array
+    {
+        return [
+            'id'           => $media->id,
+            'url'          => $media->getUrl(),
+            'thumb_url'    => $media->hasGeneratedConversion('thumb') ? $media->getUrl('thumb') : $media->getUrl(),
+            'file_name'    => $media->file_name,
+            'mime_type'    => $media->mime_type,
+            'size'         => $media->size,
+            'position'     => $media->pivot?->position,
+        ];
+    }
+
     /**
      * Trigger price reindex for a product and its flexible variants.
      */

+ 109 - 0
packages/Longyi/Core/src/Listeners/VariantInventoryListener.php

@@ -0,0 +1,109 @@
+<?php
+
+namespace Longyi\Core\Listeners;
+
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+use Longyi\Core\Models\ProductVariant;
+
+/**
+ * Keeps `product_variants.quantity` in sync with order lifecycle events.
+ *
+ * Bagisto's OrderItemRepository::manageInventory only knows about the standard
+ * `product_inventories` / `product_ordered_inventories` tables; flexible_variant
+ * carries inventory on its own table, so we adjust it here.
+ *
+ * Strategy: decrement at order save, restore on cancel. Refunds are not handled
+ * yet — extend with `sales.refund.save.after` if needed.
+ */
+class VariantInventoryListener
+{
+    /**
+     * Decrement variant stock for every order item that carries a `variant_id`.
+     */
+    public function decrementOnOrderSave($order): void
+    {
+        if (! $order || ! method_exists($order, 'getRelationValue')) {
+            return;
+        }
+
+        try {
+            DB::transaction(function () use ($order) {
+                foreach ($order->items as $item) {
+                    $variantId = $this->extractVariantId($item);
+                    $qty = (int) ($item->qty_ordered ?? 0);
+
+                    if (! $variantId || $qty <= 0) {
+                        continue;
+                    }
+
+                    ProductVariant::whereKey($variantId)->decrement('quantity', $qty);
+                }
+            });
+        } catch (\Throwable $e) {
+            Log::error('VariantInventoryListener::decrementOnOrderSave failed', [
+                'order_id' => $order->id ?? null,
+                'error'    => $e->getMessage(),
+            ]);
+        }
+    }
+
+    /**
+     * Restore variant stock when an order (or a portion of it) is cancelled.
+     */
+    public function restoreOnOrderCancel($order): void
+    {
+        if (! $order) {
+            return;
+        }
+
+        try {
+            DB::transaction(function () use ($order) {
+                foreach ($order->items as $item) {
+                    $variantId = $this->extractVariantId($item);
+
+                    if (! $variantId) {
+                        continue;
+                    }
+
+                    $remaining = (int) ($item->qty_ordered ?? 0)
+                        - (int) ($item->qty_canceled ?? 0)
+                        - (int) ($item->qty_refunded ?? 0);
+
+                    if ($remaining > 0) {
+                        ProductVariant::whereKey($variantId)->increment('quantity', $remaining);
+                    }
+                }
+            });
+        } catch (\Throwable $e) {
+            Log::error('VariantInventoryListener::restoreOnOrderCancel failed', [
+                'order_id' => $order->id ?? null,
+                'error'    => $e->getMessage(),
+            ]);
+        }
+    }
+
+    /**
+     * Pull `variant_id` out of an order item's `additional` payload.
+     *
+     * `additional` is normally a JSON column cast to array, but be defensive in case
+     * it arrives as a JSON string.
+     */
+    protected function extractVariantId($item): ?int
+    {
+        $additional = $item->additional ?? null;
+
+        if (is_string($additional)) {
+            $decoded = json_decode($additional, true);
+            $additional = is_array($decoded) ? $decoded : null;
+        }
+
+        if (! is_array($additional)) {
+            return null;
+        }
+
+        $variantId = $additional['variant_id'] ?? null;
+
+        return $variantId ? (int) $variantId : null;
+    }
+}

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

@@ -0,0 +1,104 @@
+<?php
+
+namespace Longyi\Core\Models;
+
+use Illuminate\Support\Facades\Storage;
+use Webkul\Product\Models\ProductImage as BaseProductImage;
+use Webkul\Product\Models\ProductProxy;
+
+/**
+ * Extended ProductImage model.
+ *
+ * Adds label, is_base_image, is_small_image, is_thumbnail columns.
+ *
+ * @property string|null $label
+ * @property bool        $is_base_image
+ * @property bool        $is_small_image
+ * @property bool        $is_thumbnail
+ */
+class ProductImage extends BaseProductImage
+{
+    /**
+     * @var array
+     */
+    protected $fillable = [
+        'type',
+        'path',
+        'product_id',
+        'position',
+        'label',
+        'is_base_image',
+        'is_small_image',
+        'is_thumbnail',
+    ];
+
+    /**
+     * @var array
+     */
+    protected $casts = [
+        'is_base_image'  => 'boolean',
+        'is_small_image' => 'boolean',
+        'is_thumbnail'   => 'boolean',
+    ];
+
+    /**
+     * @var array
+     */
+    protected $appends = ['url', 'roles'];
+
+    /**
+     * Get image roles as a plain array for easy front-end consumption.
+     *
+     * @return array<string>
+     */
+    public function getRolesAttribute(): array
+    {
+        $roles = [];
+
+        if ($this->is_base_image) {
+            $roles[] = 'base_image';
+        }
+
+        if ($this->is_small_image) {
+            $roles[] = 'small_image';
+        }
+
+        if ($this->is_thumbnail) {
+            $roles[] = 'thumbnail';
+        }
+
+        return $roles;
+    }
+
+    /**
+     * Convenience: check if this image has a specific role.
+     */
+    public function hasRole(string $role): bool
+    {
+        return in_array($role, $this->getRolesAttribute(), true);
+    }
+
+    /**
+     * Scope: filter images that are designated as base image.
+     */
+    public function scopeBaseImage($query)
+    {
+        return $query->where('is_base_image', true);
+    }
+
+    /**
+     * Scope: filter images that are designated as small image.
+     */
+    public function scopeSmallImage($query)
+    {
+        return $query->where('is_small_image', true);
+    }
+
+    /**
+     * Scope: filter images that are designated as thumbnail.
+     */
+    public function scopeThumbnail($query)
+    {
+        return $query->where('is_thumbnail', true);
+    }
+}

+ 69 - 44
packages/Longyi/Core/src/Models/ProductVariant.php

@@ -8,27 +8,32 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 use Illuminate\Database\Eloquent\Relations\MorphMany;
 use Illuminate\Database\Eloquent\SoftDeletes;
 use Longyi\Core\Contracts\ProductVariant as ProductVariantContract;
+use Spatie\MediaLibrary\HasMedia;
+use Spatie\MediaLibrary\InteractsWithMedia;
+use Spatie\MediaLibrary\MediaCollections\Models\Media;
 use Webkul\Product\Models\Product;
 use Webkul\Product\Models\ProductCustomerGroupPriceProxy;
-use Webkul\Product\Models\ProductImageProxy;
 use Webkul\Product\Models\ProductPriceIndexProxy;
 
 /**
- * @property int    $id
- * @property int    $product_id
- * @property string $sku
- * @property string $name
- * @property float  $price
- * @property float  $cost
- * @property float  $weight
- * @property int    $quantity
+ * @property int     $id
+ * @property int     $product_id
+ * @property string  $sku
+ * @property string  $name
+ * @property float   $price
+ * @property float   $cost
+ * @property float   $weight
+ * @property int     $quantity
  * @property boolean $status
- * @property array  $images
- * @property int    $sort_order
+ * @property int     $sort_order
  */
-class ProductVariant extends Model implements ProductVariantContract
+class ProductVariant extends Model implements ProductVariantContract, HasMedia
 {
     use SoftDeletes;
+    use InteractsWithMedia;
+
+    /** Collection name for variant images. */
+    const IMAGES_COLLECTION = 'variant_images';
 
     /**
      * The table associated with the model.
@@ -64,18 +69,18 @@ class ProductVariant extends Model implements ProductVariantContract
      * @var array
      */
     protected $casts = [
-        'product_id' => 'integer',
-        'price' => 'decimal:4',
-        'compare_price' => 'decimal:4',
-        'special_price' => 'decimal:4',
-        'cost' => 'decimal:4',
-        'weight' => 'decimal:4',
-        'quantity' => 'integer',
-        'status' => 'boolean',
-        'sort_order' => 'integer',
+        'product_id'        => 'integer',
+        'price'             => 'decimal:4',
+        'compare_price'     => 'decimal:4',
+        'special_price'     => 'decimal:4',
+        'cost'              => 'decimal:4',
+        'weight'            => 'decimal:4',
+        'quantity'          => 'integer',
+        'status'            => 'boolean',
+        'sort_order'        => 'integer',
         'special_price_from' => 'date',
-        'special_price_to' => 'date',
-        'deleted_at' => 'datetime',
+        'special_price_to'  => 'date',
+        'deleted_at'        => 'datetime',
     ];
 
     /**
@@ -85,15 +90,24 @@ class ProductVariant extends Model implements ProductVariantContract
     {
         parent::boot();
 
-        // When deleting a variant, detach all option values
         static::deleting(function ($variant) {
             $variant->values()->detach();
-            $variant->images()->detach();
+            $variant->variantImages()->detach();
         });
     }
 
     /**
-     * Get the parent product
+     * Register media collections.
+     * All uploaded variant images are stored in the 'variant_images' collection.
+     */
+    public function registerMediaCollections(): void
+    {
+        $this->addMediaCollection(self::IMAGES_COLLECTION)
+            ->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
+    }
+
+    /**
+     * Get the parent product.
      */
     public function product(): BelongsTo
     {
@@ -107,6 +121,7 @@ class ProductVariant extends Model implements ProductVariantContract
     {
         return $this->morphMany(ProductPriceIndexProxy::modelClass(), 'priceable');
     }
+
     public function basePrices(): MorphMany
     {
         return $this->price_indices()->whereNull('customer_group_id');
@@ -121,7 +136,7 @@ class ProductVariant extends Model implements ProductVariantContract
     }
 
     /**
-     * Get all option values for this variant (many-to-many)
+     * Get all option values for this variant (many-to-many).
      */
     public function values(): BelongsToMany
     {
@@ -134,7 +149,31 @@ class ProductVariant extends Model implements ProductVariantContract
     }
 
     /**
-     * Check if variant is saleable
+     * Pivot relation: media entries linked to this variant via product_variant_images.
+     * Use this for attaching/detaching/syncing existing Media records.
+     */
+    public function variantImages(): BelongsToMany
+    {
+        return $this->belongsToMany(
+            Media::class,
+            'product_variant_images',
+            'product_variant_id',
+            'media_id'
+        )->withPivot('position')
+         ->orderByPivot('position');
+    }
+
+    /**
+     * Helper: get all images for this variant (pivot + own media collection merged).
+     * Returns pivot images first (in position order), then any directly-owned media.
+     */
+    public function getAllImages(): \Illuminate\Support\Collection
+    {
+        return $this->variantImages()->get();
+    }
+
+    /**
+     * Check if variant is saleable.
      */
     public function isSaleable(): bool
     {
@@ -192,21 +231,7 @@ class ProductVariant extends Model implements ProductVariantContract
     }
 
     /**
-     * Get the images for this variant (many-to-many with product_images).
-     */
-    public function images(): BelongsToMany
-    {
-        return $this->belongsToMany(
-            ProductImageProxy::modelClass(),
-            'product_variant_images',
-            'product_variant_id',
-            'product_image_id'
-        )->withPivot('position')
-         ->orderByPivot('position');
-    }
-
-    /**
-     * Scope to get only active variants
+     * Scope to get only active variants.
      */
     public function scopeActive($query)
     {
@@ -214,7 +239,7 @@ class ProductVariant extends Model implements ProductVariantContract
     }
 
     /**
-     * Scope to get only saleable variants
+     * Scope to get only saleable variants.
      */
     public function scopeSaleable($query)
     {

+ 35 - 1
packages/Longyi/Core/src/Providers/LongyiCoreServiceProvider.php

@@ -3,8 +3,10 @@
 namespace Longyi\Core\Providers;
 
 use Illuminate\Support\Facades\Blade;
-use Illuminate\Support\ServiceProvider;
+use Illuminate\Support\Facades\Event;
 use Illuminate\Support\Facades\Route;
+use Illuminate\Support\ServiceProvider;
+use Longyi\Core\Listeners\VariantInventoryListener;
 
 class LongyiCoreServiceProvider extends ServiceProvider
 {
@@ -34,6 +36,18 @@ class LongyiCoreServiceProvider extends ServiceProvider
         $this->publishes([
             __DIR__ . '/../Resources/lang' => lang_path('vendor/longyi'),
         ], 'longyi-lang');
+
+        $this->registerEventListeners();
+    }
+
+    /**
+     * Hook variant inventory adjustments into Bagisto's order lifecycle.
+     */
+    protected function registerEventListeners(): void
+    {
+        Event::listen('checkout.order.save.after', [VariantInventoryListener::class, 'decrementOnOrderSave']);
+
+        Event::listen('sales.order.cancel.after', [VariantInventoryListener::class, 'restoreOnOrderCancel']);
     }
 
     /**
@@ -85,6 +99,19 @@ class LongyiCoreServiceProvider extends ServiceProvider
             \Longyi\Core\Contracts\ProductVariant::class,
             \Longyi\Core\Models\ProductVariant::class
         );
+
+        // Replace Webkul's ProductImage Model with our extended version that adds
+        // label, is_base_image, is_small_image, is_thumbnail support.
+        $this->app->bind(
+            \Webkul\Product\Contracts\ProductImage::class,
+            \Longyi\Core\Models\ProductImage::class
+        );
+
+
+        $this->app->singleton(
+            \Webkul\Product\ProductImage::class,
+            \Longyi\Core\Helpers\ProductImage::class
+        );
     }
 
     /**
@@ -103,5 +130,12 @@ class LongyiCoreServiceProvider extends ServiceProvider
         $this->app->singleton(
             \Longyi\Core\Repositories\ProductVariantRepository::class
         );
+
+        // Override Webkul's ProductImageRepository with our extended version
+        // that supports label, is_base_image, is_small_image, is_thumbnail.
+        $this->app->singleton(
+            \Webkul\Product\Repositories\ProductImageRepository::class,
+            \Longyi\Core\Repositories\ProductImageRepository::class
+        );
     }
 }

+ 128 - 0
packages/Longyi/Core/src/Repositories/ProductImageRepository.php

@@ -0,0 +1,128 @@
+<?php
+
+namespace Longyi\Core\Repositories;
+
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Str;
+use Intervention\Image\ImageManager;
+use Webkul\Product\Repositories\ProductImageRepository as BaseProductImageRepository;
+
+/**
+ * Extended ProductImageRepository.
+ *
+ * Overrides the Webkul upload pipeline to support the extra columns:
+ *   label, is_base_image, is_small_image, is_thumbnail.
+ *
+ * The extra data is expected inside $data['images']['meta'], keyed by
+ * the same index used in $data['images']['files']:
+ *
+ * $data['images'] = [
+ *     'files' => [
+ *         0 => <UploadedFile>,   // new upload
+ *         7 => 7,                // existing image id to keep
+ *     ],
+ *     'meta' => [
+ *         0 => ['label' => 'Front', 'is_base_image' => true, 'is_small_image' => true, 'is_thumbnail' => false],
+ *         7 => ['label' => 'Back',  'is_base_image' => false, ...],
+ *     ],
+ * ]
+ */
+class ProductImageRepository extends BaseProductImageRepository
+{
+    public function model(): string
+    {
+        return \Webkul\Product\Contracts\ProductImage::class;
+    }
+
+    /**
+     * Upload & sync product images including label / role meta.
+     */
+    public function upload($data, $product, string $uploadFileType = 'images'): void
+    {
+        $previousIds = $product->images()->pluck('id');
+
+        $position = 0;
+        $files    = $data[$uploadFileType]['files'] ?? [];
+        $meta     = $data[$uploadFileType]['meta']  ?? [];
+
+        foreach ($files as $indexOrModelId => $file) {
+            $imageMeta = $this->extractMeta($meta[$indexOrModelId] ?? []);
+
+            if ($file instanceof UploadedFile) {
+                $path = $this->storeFile($file, $product);
+
+                $this->create(array_merge([
+                    'type'       => $uploadFileType,
+                    'path'       => $path,
+                    'product_id' => $product->id,
+                    'position'   => ++$position,
+                ], $imageMeta));
+            } else {
+                // $file is an existing model ID — keep it, update position & meta
+                if (is_numeric($index = $previousIds->search($indexOrModelId))) {
+                    $previousIds->forget($index);
+                }
+
+                $this->update(array_merge(['position' => ++$position], $imageMeta), $indexOrModelId);
+            }
+        }
+
+        // Delete images that were removed
+        foreach ($previousIds as $modelId) {
+            if ($model = $this->find($modelId)) {
+                Storage::delete($model->path);
+                $this->delete($modelId);
+            }
+        }
+    }
+
+    /**
+     * Update only the meta fields (label / roles) for a single image.
+     * Does NOT change the file or position.
+     */
+    public function updateImageMeta(int $imageId, array $meta): bool
+    {
+        $image = $this->find($imageId);
+
+        if (! $image) {
+            return false;
+        }
+
+        $image->update($this->extractMeta($meta));
+
+        return true;
+    }
+
+    /**
+     * Store a file and return its path.
+     */
+    protected function storeFile(UploadedFile $file, $product): string
+    {
+        $directory = 'product/' . $product->id;
+
+        if (Str::contains($file->getMimeType(), 'image')) {
+            $manager = new ImageManager;
+            $image   = $manager->make($file)->encode('webp');
+            $path    = $directory . '/' . Str::random(40) . '.webp';
+            Storage::put($path, $image);
+
+            return $path;
+        }
+
+        return $file->store($directory);
+    }
+
+    /**
+     * Extract and normalise the meta fields from raw input.
+     */
+    protected function extractMeta(array $raw): array
+    {
+        return [
+            'label'          => $raw['label']          ?? null,
+            'is_base_image'  => ! empty($raw['is_base_image']),
+            'is_small_image' => ! empty($raw['is_small_image']),
+            'is_thumbnail'   => ! empty($raw['is_thumbnail']),
+        ];
+    }
+}

+ 1 - 1
packages/Longyi/Core/src/Repositories/ProductVariantRepository.php

@@ -30,7 +30,7 @@ class ProductVariantRepository extends Repository
     {
         $query = $this->model
             ->where('product_id', $productId)
-            ->with('values.option')
+            ->with(['values.option','variantImages'])
             ->orderBy('sort_order');
 
         if ($onlyActive) {

Разница между файлами не показана из-за своего большого размера
+ 338 - 126
packages/Longyi/Core/src/Resources/views/admin/catalog/products/edit/types/flexible_variant.blade.php


Разница между файлами не показана из-за своего большого размера
+ 191 - 0
packages/Longyi/Core/src/Resources/views/admin/catalog/products/edit/types/flexible_variant/mediaupload.blade copy.php


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

@@ -0,0 +1,206 @@
+<script type="text/x-template" id="flexiblevariant-uploaddialog-template">
+    <el-dialog title="Image Upload" width="660" class="flexiblevariant-uploaddialog"
+        :model-value="isShow" @update:model-value="$emit('update:isShow', $event)"
+        @closed="closeHandler"
+    >
+        <div>
+             <el-upload
+                v-model:file-list="fileList"
+                ref="uploadRef"
+                :auto-upload="false"
+                :http-request="submitUpload"
+                list-type="picture"
+                :limit="1"
+                :on-remove="removeImage"
+                :on-change="uploadChange"
+                :on-exceed="uploadExceed"
+                :before-upload="beforeUpload"
+                :before-remove="beforeRemove"
+            >
+                
+                <template #trigger>
+                    <el-button type="primary">select file</el-button>
+                </template>
+                <template #tip>
+                    <div class="el-upload__tip">
+                        <p class="text-xs">Image files with a size less than 500kb.</p>
+                        <p class="text-xs">Only one image is allowed to be uploaded.</p>
+                    </div>
+                </template>
+                
+            </el-upload>
+        </div>
+
+     
+        <template #footer>
+            <div class="dialog-footer">
+                <el-button @click="cancelHandler">Cancel</el-button>
+                <el-button type="primary" @click="confirmHandler">Upload</el-button>
+            </div>
+        </template>
+
+    </el-dialog>
+</script>
+<script type="module">
+
+        app.component('flexiblevariant-uploaddialog', {
+            template: '#flexiblevariant-uploaddialog-template',
+
+            props: {
+
+                paramData: {
+                    type: Object,
+                    default: function() {
+                        return {};
+                    }
+                },
+                isShow: {
+                    type: Boolean,
+                    default: false
+                }
+                
+            },
+            emits: ['update:isShow','confirm','cancel','delete'], // 显式声明事件(推荐)
+            data() {
+                return {
+                    fileList: [],
+                };
+            },
+            watch: {
+                'paramData.variantImages':{
+                    handler(newVal, oldVal) {
+                        if(newVal.length) {
+                            this.fileList = [{
+                                id: newVal[0].id,
+                                name: newVal[0].file_name,
+                                url: newVal[0].original_url
+                            }];
+                        } else {
+                            this.fileList = [];
+                        }
+                        
+                    },
+                    
+                }
+            },
+
+            mounted() {
+                
+            },
+
+            methods: {
+                async beforeRemove() {
+                    let res = await this.$confirm('This operation cannot be undone.',{
+                        title: 'Are you sure to delete?',
+                        showClose: false,
+                        confirmButtonText: 'Delete',
+                        cancelButtonText: 'Cancel',
+                        type: 'warning',
+                    }).then(() => true).catch(() => false);
+
+                    return res;
+                },
+                removeImage(file,fileList) {
+                    if(file.status === 'success' && file.id) {
+                        let loading = this.$loading({
+                            target: '.flexiblevariant-uploaddialog'
+                        });
+                        axios.delete(`/admin/flexible-variant/variants/images/${file.id}`, {
+                            data:{
+                                variant_ids: this.paramData.variantIds
+                            }
+                        }).then((result) => {
+                            this.$message({
+                                message: 'Delete Success',
+                                type: 'success'
+                            });
+                            this.$emit('delete', {
+                                variantIds: this.paramData.variantIds,
+                                variant_images:[]
+                            });
+                            loading.close();
+                        }).catch((error) => {
+                            this.$message({
+                                message: error.message,
+                                type: 'error'
+                            });
+                            loading.close();
+                        });
+                    }
+                },
+                uploadExceed(files) {
+                    this.$refs.uploadRef.clearFiles();
+                    const file = files[0];
+                    file.uid = this.$genFileId();
+                    this.$refs.uploadRef.handleStart(file);
+                },
+                uploadChange(addFile, fileList) {
+                    console.log(addFile.size,addFile,fileList);
+                },
+                beforeUpload(rawFile) {
+                    let res = true;
+                    if(rawFile.size > 5000000) {
+                        this.$message({
+                            message: 'Image files with a size less than 500kb.',
+                            type: 'error'
+                        });
+                        res = false;
+                    }
+                    return res;
+                },
+                submitUpload(option) {
+                    let loading = this.$loading({
+                        target: '.flexiblevariant-uploaddialog'
+                    });
+                    const formData = new FormData();
+                    formData.append('file', option.file);
+                    this.paramData.variantIds.forEach((id) => {
+                        formData.append('variant_ids[]', id);
+                    });
+                    axios.post(`/admin/flexible-variant/variants/images`, formData, {
+                        headers: { 'Content-Type': 'multipart/form-data' }
+                    }).then((result) => {
+                        /**
+                         * 1.更新fileList
+                         * 2.触发confirm事件,把新的variant数据发给父组件
+                         * 3.关闭dialog
+                        */
+                        this.fileList = [{
+                            name: result.data.data.file_name,
+                            url: result.data.data.thumb_url
+                        }];
+                        this.$emit('confirm', {
+                            variantIds: this.paramData.variantIds,
+                            variant_images:[{
+                                file_name: result.data.data.file_name,
+                                original_url: result.data.data.url,
+                                id: result.data.data.id,
+                            }]
+                        });
+                        this.$emit('update:isShow', false);
+                        loading.close();
+
+                    }).catch((error) => {
+                        this.$message({
+                            message: error.message,
+                            type: 'error'
+                        });
+                        loading.close();
+                    });
+
+                },
+
+                confirmHandler() {
+                    this.$refs.uploadRef.submit()
+                },
+                cancelHandler() {
+                    this.$emit('update:isShow', false);
+                    this.$emit('cancel', {});
+                },
+                closeHandler() {
+                    this.fileList = [];
+                }
+            },
+
+        });
+</script>

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

@@ -32,7 +32,9 @@ Route::group(['middleware' => ['admin']], function () {
             Route::post('{id}/save-variants', [FlexibleVariantController::class, 'saveVariants'])->name('admin.flexible_variant.variants.save-variants');
             Route::put('/{id}', [FlexibleVariantController::class, 'updateVariant'])->name('admin.flexible_variant.variants.update');
             Route::delete('/{id}', [FlexibleVariantController::class, 'deleteVariant'])->name('admin.flexible_variant.variants.destroy');
-            Route::post('/{id}/images', [FlexibleVariantController::class, 'syncVariantImages'])->name('admin.flexible_variant.variants.images.sync');
+            Route::post('/images', [FlexibleVariantController::class, 'syncVariantImages'])->name('admin.flexible_variant.variants.images.sync');
+            Route::post('/{id}/images/upload', [FlexibleVariantController::class, 'uploadVariantImage'])->name('admin.flexible_variant.variants.images.upload');
+            Route::delete('/images/{mediaId}', [FlexibleVariantController::class, 'removeVariantImage'])->name('admin.flexible_variant.variants.images.remove');
 
             // Bulk operations
             Route::post('/bulk/quantities', [FlexibleVariantController::class, 'bulkUpdateQuantities'])->name('admin.flexible_variant.variants.bulk.quantities');

+ 247 - 3
packages/Longyi/Core/src/Type/FlexibleVariant.php

@@ -3,24 +3,35 @@
 namespace Longyi\Core\Type;
 
 use Longyi\Core\Helpers\Indexers\Price\FlexibleVariant as FlexibleVariantIndexer;
+use Longyi\Core\Models\ProductVariant;
+use Longyi\Core\Repositories\ProductVariantRepository;
+use Webkul\Checkout\Models\CartItem;
+use Webkul\Product\DataTypes\CartItemValidationResult;
+use Webkul\Product\Exceptions\InsufficientProductInventoryException;
 use Webkul\Product\Type\AbstractType;
 
 class FlexibleVariant extends AbstractType
 {
     /**
      * Is a composite product type.
+     *
+     * Kept true so OrderItemRepository::manageInventory iterates `children` (empty for
+     * flexible_variant cart rows) and skips the simple-product branch. Variant stock is
+     * adjusted by Longyi\Core\Listeners\VariantInventoryListener.
      */
     protected $isComposite = true;
 
     /**
      * Is a stockable product type.
+     *
+     * Variants ship physical goods, so checkout requires shipping address + method.
      */
-    protected $isStockable = false;
+    protected $isStockable = true;
 
     /**
      * Show quantity box.
      */
-    protected $showQuantityBox = false;
+    protected $showQuantityBox = true;
 
     /**
      * Has child products i.e. variants.
@@ -198,6 +209,239 @@ class FlexibleVariant extends AbstractType
 
     public function isSaleable(): bool
     {
-        return $this->getSaleableVariants()->isNotEmpty();
+        // return $this->getSaleableVariants()->isNotEmpty()&&parent::isSaleable();
+        return parent::isSaleable();
+    }
+
+    /**
+     * Resolve the selected variant from cart input data.
+     *
+     * Priority: explicit `variant_id` -> `super_attribute` map (option_id => option_value_id).
+     */
+    protected function resolveVariant(array $data): ?ProductVariant
+    {
+        $repository = app(ProductVariantRepository::class);
+
+        $variantId = $data['variant_id'] ?? null;
+
+        if ($variantId) {
+            $variant = $repository->find((int) $variantId);
+
+            return ($variant && (int) $variant->product_id === (int) $this->product->id) ? $variant : null;
+        }
+
+        $superAttribute = $data['super_attribute'] ?? null;
+
+        if (is_array($superAttribute) && ! empty($superAttribute)) {
+            $valueIds = array_values(array_map('intval', $superAttribute));
+
+            return $repository->findByOptionValues((int) $this->product->id, $valueIds);
+        }
+
+        return null;
+    }
+
+    /**
+     * Build the cart row for the selected variant.
+     *
+     * Returns a single cart_item array; variant identity is preserved in `additional.variant_id`
+     * so that compareOptions/getItemByProduct can keep different variants of the same parent
+     * product as separate cart rows while merging duplicates.
+     */
+    public function prepareForCart($data)
+    {
+        $variant = $this->resolveVariant(is_array($data) ? $data : []);
+
+        if (! $variant) {
+            return trans('shop::app.checkout.cart.missing-options');
+        }
+
+        if (! $variant->status) {
+            return trans('shop::app.checkout.cart.inactive-add');
+        }
+
+        $data['variant_id'] = (int) $variant->id;
+        $data['quantity'] = $this->handleQuantity((int) ($data['quantity'] ?? 1));
+
+        $data = $this->getQtyRequest($data);
+
+        if ($variant->quantity < (int) $data['quantity']) {
+            throw new InsufficientProductInventoryException(trans('product::app.checkout.cart.inventory-warning'));
+        }
+
+        $price = $this->getVariantPrice($variant);
+        $convertedPrice = core()->convertPrice($price);
+        $weight = (float) ($variant->weight ?? $this->product->weight ?? 0);
+
+        return [
+            [
+                'product_id'          => $this->product->id,
+                'sku'                 => $variant->sku ?: $this->product->sku,
+                'name'                => $variant->name ?: $this->product->name,
+                'type'                => $this->product->type,
+                'quantity'            => $data['quantity'],
+                'price'               => $convertedPrice,
+                'price_incl_tax'      => $convertedPrice,
+                'base_price'          => $price,
+                'base_price_incl_tax' => $price,
+                'total'               => $convertedPrice * $data['quantity'],
+                'total_incl_tax'      => $convertedPrice * $data['quantity'],
+                'base_total'          => $price * $data['quantity'],
+                'base_total_incl_tax' => $price * $data['quantity'],
+                'weight'              => $weight,
+                'total_weight'        => $weight * $data['quantity'],
+                'base_total_weight'   => $weight * $data['quantity'],
+                'additional'          => $this->getAdditionalOptions($data),
+            ],
+        ];
+    }
+
+    /**
+     * Compose `additional` payload persisted on the cart item.
+     *
+     * `product_id` is required so Cart::getItemByProduct triggers compareOptions.
+     * Selected option labels are denormalised here for display in cart/order views.
+     */
+    public function getAdditionalOptions($data)
+    {
+        $variantId = $data['variant_id'] ?? null;
+
+        if (! $variantId) {
+            return $data;
+        }
+
+        $variant = ProductVariant::with('values.option')->find((int) $variantId);
+
+        $data['product_id'] = (int) $this->product->id;
+        $data['variant_id'] = (int) $variantId;
+
+        if ($variant) {
+            $data['variant_sku'] = $variant->sku;
+            $data['variant_name'] = $variant->name;
+
+            $attributes = [];
+
+            foreach ($variant->values as $value) {
+                $option = $value->option;
+
+                if (! $option) {
+                    continue;
+                }
+
+                $attributes[$option->code] = [
+                    'option_id'      => (int) $option->id,
+                    'option_code'    => $option->code,
+                    'option_label'   => $option->label,
+                    'value_id'       => (int) $value->id,
+                    'value_code'     => $value->code,
+                    'value_label'    => $value->label,
+                    'attribute_name' => $option->label,
+                ];
+            }
+
+            $data['attributes'] = $attributes;
+        }
+
+        return $data;
+    }
+
+    /**
+     * Two cart rows are mergeable iff they reference the same parent product AND
+     * the same variant.
+     */
+    public function compareOptions($options1, $options2)
+    {
+        if ((int) $this->product->id !== (int) ($options2['product_id'] ?? 0)) {
+            return false;
+        }
+
+        $variant1 = $options1['variant_id'] ?? null;
+        $variant2 = $options2['variant_id'] ?? null;
+
+        if (! $variant1 || ! $variant2) {
+            return false;
+        }
+
+        return (int) $variant1 === (int) $variant2;
+    }
+
+    /**
+     * Per-variant inventory check (overrides the parent's product-level aggregate).
+     */
+    public function isItemHaveQuantity($cartItem)
+    {
+        $variantId = $cartItem->additional['variant_id'] ?? null;
+
+        if (! $variantId) {
+            return false;
+        }
+
+        $variant = ProductVariant::find((int) $variantId);
+
+        if (! $variant || ! $variant->status) {
+            return false;
+        }
+
+        return $variant->quantity >= (int) $cartItem->quantity;
+    }
+
+    /**
+     * Cart-level validation: re-resolve variant, ensure it is still active and the
+     * persisted price matches the current effective price.
+     */
+    public function validateCartItem(CartItem $item): CartItemValidationResult
+    {
+        $validation = new CartItemValidationResult;
+
+        if ($this->isCartItemInactive($item)) {
+            $validation->itemIsInactive();
+
+            return $validation;
+        }
+
+        $variantId = $item->additional['variant_id'] ?? null;
+        $variant = $variantId ? ProductVariant::find((int) $variantId) : null;
+
+        if (! $variant || ! $variant->status) {
+            $validation->itemIsInactive();
+
+            return $validation;
+        }
+
+        $basePrice = round($this->getVariantPrice($variant), 4);
+
+        if ($basePrice == $item->base_price_incl_tax) {
+            return $validation;
+        }
+
+        $item->base_price = $basePrice;
+        $item->base_price_incl_tax = $basePrice;
+
+        $price = core()->convertPrice($basePrice);
+        $item->price = $price;
+        $item->price_incl_tax = $price;
+
+        $item->base_total = $basePrice * $item->quantity;
+        $item->base_total_incl_tax = $basePrice * $item->quantity;
+
+        $total = core()->convertPrice($basePrice * $item->quantity);
+        $item->total = $total;
+        $item->total_incl_tax = $total;
+
+        $item->save();
+
+        return $validation;
+    }
+
+    /**
+     * Effective price for a single variant, channel/customer-group aware.
+     */
+    protected function getVariantPrice(ProductVariant $variant): float
+    {
+        $customerGroupId = app(\Webkul\Customer\Repositories\CustomerRepository::class)
+            ->getCurrentGroup()->id;
+        $channelId = core()->getCurrentChannel()->id;
+
+        return (float) $variant->getEffectivePrice($customerGroupId, $channelId);
     }
 }

+ 23 - 0
packages/Longyi/Gift/package.json

@@ -0,0 +1,23 @@
+{
+  "private": true,
+  "scripts": {
+    "dev": "vite",
+    "build": "vite build"
+  },
+  "devDependencies": {
+    "autoprefixer": "^10.4.14",
+    "axios": "^1.4.0",
+    "laravel-vite-plugin": "^0.7.2",
+    "postcss": "^8.4.23",
+    "tailwindcss": "^3.3.2",
+    "vite": "^4.0.0",
+    "vue": "^3.2.47"
+  },
+  "dependencies": {
+    "@vee-validate/i18n": "^4.9.1",
+    "@vee-validate/rules": "^4.9.1",
+    "@vitejs/plugin-vue": "^4.2.3",
+    "mitt": "^3.0.1",
+    "vee-validate": "^4.9.1"
+  }
+}

+ 6 - 0
packages/Longyi/Gift/postcss.config.js

@@ -0,0 +1,6 @@
+module.exports = {
+  plugins: {
+    tailwindcss: {},
+    autoprefixer: {},
+  },
+}

+ 28 - 0
packages/Longyi/Gift/src/Config/acl.php

@@ -0,0 +1,28 @@
+<?php
+
+return [
+    [
+        'key'   => 'gift',
+        'name'  => '礼品卡管理',
+        'route' => 'admin.gift.index',
+        'sort'  => 2
+    ],
+    [
+        'key'   => 'gift.create',
+        'name'  => '创建礼品卡',
+        'route' => 'admin.gift.store',
+        'sort'  => 1
+    ],
+    [
+        'key'   => 'gift.edit',
+        'name'  => '编辑礼品卡',
+        'route' => 'admin.gift.update',
+        'sort'  => 2
+    ],
+    [
+        'key'   => 'gift.delete',
+        'name'  => '删除礼品卡',
+        'route' => 'admin.gift.destroy',
+        'sort'  => 3
+    ]
+];

+ 11 - 0
packages/Longyi/Gift/src/Config/admin-menu.php

@@ -0,0 +1,11 @@
+<?php
+
+return [
+    [
+        'key'   => 'gift',
+        'name'  => 'Gift',
+        'route' => 'admin.gift.index',
+        'sort'  => 100,
+        'icon'  => 'icon-sales',
+    ]
+];

+ 7 - 0
packages/Longyi/Gift/src/Contracts/GiftCardUsageLog.php

@@ -0,0 +1,7 @@
+<?php
+
+namespace Longyi\Gift\Contracts;
+
+interface GiftCardUsageLog
+{
+}

+ 7 - 0
packages/Longyi/Gift/src/Contracts/GiftCards.php

@@ -0,0 +1,7 @@
+<?php
+
+namespace Longyi\Gift\Contracts;
+
+interface GiftCards
+{
+}

+ 217 - 0
packages/Longyi/Gift/src/DataGrids/GiftCards/GiftCardsDataGrid.php

@@ -0,0 +1,217 @@
+<?php
+
+namespace Longyi\Gift\DataGrids\GiftCards;
+
+use Illuminate\Support\Facades\DB;
+use Webkul\DataGrid\DataGrid;
+use Longyi\Gift\Models\GiftCardUsageLog;
+
+class GiftCardsDataGrid extends DataGrid
+{
+    /**
+     * Prepare query builder.
+     *
+     * @return \Illuminate\Database\Query\Builder
+     */
+    public function prepareQueryBuilder()
+    {
+        $queryBuilder = DB::table('gift_cards')
+            ->leftJoin('customers', 'gift_cards.customer_id', '=', 'customers.id')
+            ->leftJoin('gift_card_usage_logs as gift_log', function ($leftJoin) {
+                $leftJoin->on('gift_log.customer_id', '=', 'gift_cards.customer_id')
+                    ->where('gift_log.action_type', GiftCardUsageLog::ACTION_ADD);
+            })
+            ->select(
+                'gift_cards.id',
+                'gift_cards.giftcard_number',
+                'gift_cards.giftcard_amount',
+                'gift_cards.used_giftcard_amount',
+                'gift_cards.remaining_giftcard_amount',
+                'gift_cards.customer_id',
+                'customers.email as customer_email',
+                'customers.first_name',
+                'customers.last_name',
+                'gift_cards.expirationdate',
+                'gift_cards.giftcard_status',
+                'gift_log.notes',
+                'gift_cards.created_at',
+                'gift_cards.updated_at'
+            );
+
+        $this->addFilter('id', 'gift_cards.id');
+        $this->addFilter('giftcard_number', 'gift_cards.giftcard_number');
+        $this->addFilter('giftcard_amount', 'gift_cards.giftcard_amount');
+        $this->addFilter('giftcard_status', 'gift_cards.giftcard_status');
+        $this->addFilter('created_at', 'gift_cards.created_at');
+        $this->addFilter('notes', 'gift_log.notes');
+        $this->addFilter('customer_email', 'customers.email');
+
+        return $queryBuilder;
+    }
+
+    /**
+     * Add columns.
+     *
+     * @return void
+     */
+    public function prepareColumns()
+    {
+        $this->addColumn([
+            'index'      => 'id',
+            'label'      => trans('gift::app.admin.datagrid.id'),
+            'type'       => 'integer',
+            'filterable' => true,
+            'sortable'   => true,
+        ]);
+
+//        $this->addColumn([
+//            'index'      => 'giftcard_number',
+//            'label'      => trans('gift::app.admin.datagrid.giftcard-number'),
+//            'type'       => 'string',
+//            'searchable' => true,
+//            'filterable' => true,
+//            'sortable'   => true,
+//        ]);
+
+        $this->addColumn([
+            'index'      => 'giftcard_amount',
+            'label'      => trans('gift::app.admin.datagrid.giftcard-amount'),
+            'type'       => 'aggregate',
+            'filterable' => false,
+            'sortable'   => true,
+            'closure'    => function ($row) {
+                return core()->formatPrice($row->giftcard_amount);
+            },
+        ]);
+
+        $this->addColumn([
+            'index'      => 'used_giftcard_amount',
+            'label'      => trans('gift::app.admin.datagrid.used-amount'),
+            'type'       => 'aggregate',
+            'filterable' => false,
+            'sortable'   => true,
+            'closure'    => function ($row) {
+                return core()->formatPrice($row->used_giftcard_amount);
+            },
+        ]);
+
+        $this->addColumn([
+            'index'      => 'remaining_giftcard_amount',
+            'label'      => trans('gift::app.admin.datagrid.remaining-amount'),
+            'type'       => 'aggregate',
+            'filterable' => false,
+            'sortable'   => true,
+            'closure'    => function ($row) {
+                return core()->formatPrice($row->remaining_giftcard_amount);
+            },
+        ]);
+        $this->addColumn([
+            'index'      => 'customer_email',
+            'label'      => '用户邮箱',
+            'type'       => 'string',
+            'filterable' => true,
+            'sortable'   => true,
+            'searchable' => true,
+            'closure'    => function ($row) {
+                if ($row->customer_email) {
+                    $customerName = '';
+                    if ($row->first_name || $row->last_name) {
+                        $customerName = ' (' . trim($row->first_name . ' ' . $row->last_name) . ')';
+                    }
+                    return $row->customer_email . $customerName;
+                }
+                return '<span class="text-gray-400">未绑定</span>';
+            },
+        ]);
+//        $this->addColumn([
+//            'index'      => 'customer_id',
+//            'label'      => trans('gift::app.admin.datagrid.customer-id'),
+//            'type'       => 'integer',
+//            'filterable' => true,
+//            'sortable'   => true,
+//        ]);
+
+        $this->addColumn([
+            'index'      => 'expirationdate',
+            'label'      => trans('gift::app.admin.datagrid.expiration-date'),
+            'type'       => 'date',
+            'filterable' => true,
+            'sortable'   => true,
+        ]);
+
+        $this->addColumn([
+            'index'      => 'giftcard_status',
+            'label'      => trans('gift::app.admin.datagrid.status'),
+            'type'       => 'boolean',
+            'filterable' => true,
+            'sortable'   => true,
+            'closure'    => function ($row) {
+                if ($row->giftcard_status == 1) {
+                    return '<span class="badge badge-md badge-success">未使用</span>';
+                } else {
+                    return '<span class="badge badge-md badge-warning">已使用</span>';
+                }
+            },
+        ]);
+        $this->addColumn([
+            'index'      => 'notes',
+            'label'      => 'notes',
+            'type'       => 'string',
+            'filterable' => true,
+            'sortable'   => true,
+        ]);
+        $this->addColumn([
+            'index'      => 'created_at',
+            'label'      => trans('gift::app.admin.datagrid.created-at'),
+            'type'       => 'datetime',
+            'filterable' => true,
+            'sortable'   => true,
+        ]);
+    }
+
+    /**
+     * Prepare actions.
+     *
+     * @return void
+     */
+    public function prepareActions()
+    {
+//        if (bouncer()->hasPermission('gift.edit')) {
+//            $this->addAction([
+//                'icon'   => 'icon-edit',
+//                'title'  => trans('gift::app.admin.datagrid.edit'),
+//                'method' => 'GET',
+//                'url'    => function ($row) {
+//                    return route('admin.gift.edit', $row->id);
+//                },
+//            ]);
+//        }
+
+        if (bouncer()->hasPermission('gift.delete')) {
+            $this->addAction([
+                'icon'   => 'icon-delete',
+                'title'  => trans('gift::app.admin.datagrid.delete'),
+                'method' => 'DELETE',
+                'url'    => function ($row) {
+                    return route('admin.gift.destroy', $row->id);
+                },
+            ]);
+        }
+    }
+
+    /**
+     * Prepare mass actions.
+     *
+     * @return void
+     */
+    public function prepareMassActions()
+    {
+        if (bouncer()->hasPermission('gift.delete')) {
+            $this->addMassAction([
+                'title'  => trans('gift::app.admin.datagrid.delete'),
+                'method' => 'POST',
+                'url'    => route('admin.gift.mass_delete'),
+            ]);
+        }
+    }
+}

+ 36 - 0
packages/Longyi/Gift/src/Database/Migrations/2026_04_01_184903_create_gift_cards_table.php

@@ -0,0 +1,36 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::create('gift_cards', function (Blueprint $table) {
+            $table->id();
+            $table->string('giftcard_number', 100)->index();
+            $table->decimal('giftcard_amount', 10, 2)->default(0)->nullable()->comment('金额');
+            $table->decimal('used_giftcard_amount', 10, 2)->default(0)->nullable()->comment('使用金额');
+            $table->decimal('remaining_giftcard_amount', 10, 2)->default(0)->nullable()->comment('剩余金额');
+            $table->integer('customer_id')->index()->default(0)->nullable();
+            $table->tinyInteger('type')->default(1)->nullable()->comment('1:普通 2:other');
+            $table->dateTime('expirationdate')->nullable()->comment('过期时间');
+            $table->string('channel', 200)->nullable()->comment('渠道');
+            $table->string('giftcard_status', 2)->default(1)->nullable()->comment('1:未使用 2:已使用');
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('gift_cards');
+    }
+};

+ 32 - 0
packages/Longyi/Gift/src/Database/Migrations/2026_04_01_230815_add_giftcard_fields_to_cart_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
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::table('cart', function (Blueprint $table) {
+            $table->string('giftcard_number', 191)->nullable()->after('applied_cart_rule_ids');
+            $table->decimal('giftcard_amount', 10, 2)->nullable()->after('giftcard_number');
+            $table->decimal('base_giftcard_amount', 10, 2)->nullable()->after('giftcard_amount');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::table('cart', function (Blueprint $table) {
+            $table->dropColumn('giftcard_number');
+            $table->dropColumn('giftcard_amount');
+            $table->dropColumn('base_giftcard_amount');
+        });
+    }
+};

+ 30 - 0
packages/Longyi/Gift/src/Database/Migrations/2026_04_01_230829_add_giftcard_fields_to_orders_table.php

@@ -0,0 +1,30 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::table('orders', function (Blueprint $table) {
+            $table->string('giftcard_number', 191)->nullable()->after('coupon_code');
+            $table->decimal('giftcard_amount', 10, 2)->nullable()->after('giftcard_number');
+            $table->decimal('base_giftcard_amount', 10, 2)->nullable()->after('giftcard_amount');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::table('orders', function (Blueprint $table) {
+            $table->dropColumn(['giftcard_number', 'giftcard_amount', 'base_giftcard_amount']);
+        });
+    }
+};

+ 30 - 0
packages/Longyi/Gift/src/Database/Migrations/2026_04_01_230841_add_giftcard_info_to_invoices_table.php

@@ -0,0 +1,30 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::table('invoices', function (Blueprint $table) {
+            $table->string('giftcard_number', 191)->nullable()->after('grand_total'); // Choose the appropriate position for the column
+            $table->decimal('giftcard_amount', 10, 2)->nullable()->after('giftcard_number'); // Using 'nullable' because not all invoices may use a giftcard
+            $table->decimal('base_giftcard_amount', 10, 2)->nullable()->after('giftcard_amount'); // Using 'nullable' because not all invoices may use a giftcard
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::table('invoices', function (Blueprint $table) {
+            $table->dropColumn(['giftcard_number', 'giftcard_amount', 'base_giftcard_amount']);
+        });
+    }
+};

+ 55 - 0
packages/Longyi/Gift/src/Database/Migrations/2026_04_10_012025_create_gift_card_usage_logs_table.php

@@ -0,0 +1,55 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::create('gift_card_usage_logs', function (Blueprint $table) {
+            $table->id();
+
+            // 礼品卡信息
+            $table->string('giftcard_number', 191)->index()->comment('礼品卡号');
+            $table->foreignId('customer_id')->nullable()->index()->comment('用户ID');
+
+            // 关联订单
+            $table->foreignId('order_id')->nullable()->index()->comment('订单ID');
+            $table->string('order_increment_id', 50)->nullable()->index()->comment('订单编号');
+
+            // 金额信息
+            $table->decimal('used_amount', 10, 2)->default(0)->comment('本次使用金额');
+            $table->decimal('balance_before', 10, 2)->default(0)->comment('使用前余额');
+            $table->decimal('balance_after', 10, 2)->default(0)->comment('使用后余额');
+
+            // 操作类型
+            $table->tinyInteger('action_type')->default(1)->comment('操作类型:1-使用 2-退款 3-手动调整');
+
+            // 状态
+            $table->string('status', 20)->default('completed')->comment('状态:completed, pending, cancelled, refunded');
+
+            // 备注
+            $table->text('notes')->nullable()->comment('备注说明');
+
+            // 时间戳
+            $table->timestamps();
+
+            // 索引
+            $table->index(['giftcard_number', 'created_at']);
+            $table->index(['order_id', 'created_at']);
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('gift_card_usage_logs');
+    }
+};

+ 182 - 0
packages/Longyi/Gift/src/Http/Controllers/Admin/GiftController.php

@@ -0,0 +1,182 @@
+<?php
+
+namespace Longyi\Gift\Http\Controllers\Admin;
+
+use Illuminate\Http\JsonResponse;
+use Illuminate\View\View;
+use Longyi\Gift\Models\GiftCards;
+use Longyi\Gift\Models\GiftCardUsageLog;
+use Webkul\Admin\Http\Controllers\Controller;
+use Longyi\Gift\DataGrids\GiftCards\GiftCardsDataGrid;
+use Longyi\Gift\Repositories\GiftCardsRepository;
+use Illuminate\Support\Str;
+use Webkul\Customer\Models\Customer;
+
+
+class GiftController extends Controller
+{
+
+    /**
+     * Create a new controller instance.
+     *
+     * @return void
+     */
+    public function __construct(
+        protected GiftCardsRepository $giftCardsRepository
+    ) {
+    }
+    /**
+     * Display a listing of the resource.
+     */
+    public function index(): View|JsonResponse
+    {
+        if (request()->ajax()) {
+             return datagrid(GiftCardsDataGrid::class)->process();
+        }
+
+        return view('gift::admin.index');
+    }
+
+    /**
+     * Show the form for creating a new resource.
+     */
+    public function create(): View
+    {
+        return view('gift::admin.create');
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     */
+    public function store(): JsonResponse|\Illuminate\Http\RedirectResponse
+    {
+        $data = request()->validate([
+            'giftcard_amount' => 'required|numeric|min:0',
+            'customer_id' => 'required|integer',
+            'expirationdate' => 'required|date_format:Y-m-d',
+            'giftcard_status' => 'required|in:1,2',
+        ]);
+
+        $data['giftcard_number'] = $this->generateUniqueGiftCardNumber();
+        $data['used_giftcard_amount'] = 0;
+        $data['type'] = 1;
+        $data['remaining_giftcard_amount'] = $data['giftcard_amount'] ?? 0;
+
+        $this->giftCardsRepository->create($data);
+        // 记录使用日志
+        GiftCardUsageLog::create([
+            'giftcard_number' => $data['giftcard_number'],
+            'customer_id' => $data['customer_id'],
+            'used_amount' => 0,
+            'balance_before' => $data['giftcard_amount'],
+            'balance_after' => $data['giftcard_amount'],
+            'action_type' => GiftCardUsageLog::ACTION_ADD,
+            'status' => 'completed',
+            'notes' => "Administrator addition",
+        ]);
+        if (request()->expectsJson()) {
+            return new JsonResponse([
+                'message' => '礼品卡创建成功',
+            ]);
+        }
+        return redirect()->route('admin.gift.index');
+    }
+
+    /**
+     * Validate customer email via AJAX and return customer_id
+     */
+    public function validateEmail(): JsonResponse
+    {
+        $email = request()->input('email');
+
+        if (empty($email)) {
+            return new JsonResponse([
+                'valid' => true,
+                'customer_id' => null,
+                'message' => '',
+            ]);
+        }
+
+        $customer = Customer::where('email', $email)->first();
+
+        if (!$customer) {
+            return new JsonResponse([
+                'valid' => false,
+                'customer_id' => null,
+                'message' => '该邮箱对应的用户不存在',
+            ], 422);
+        }
+
+        return new JsonResponse([
+            'valid' => true,
+            'customer_id' => $customer->id,
+            'message' => '邮箱验证通过',
+            'customer_name' => $customer->first_name . ' ' . $customer->last_name,
+        ]);
+    }
+    /**
+     * Generate a unique 16-digit gift card number
+     *
+     * @return string
+     */
+    private function generateUniqueGiftCardNumber()
+    {
+        do {
+            // Generate a random string of characters
+            $randomString = Str::upper(Str::random(16));
+
+            // Format the string into groups of four separated by dashes
+            $code = implode('-', str_split($randomString, 4));
+
+            // Check if the code already exists in the database
+            $exists = GiftCards::where('giftcard_number', $code)->exists();
+        } while ($exists);
+
+        return $code;
+    }
+    /**
+     * Remove the specified resource from storage.
+     */
+    public function destroy(int $id): JsonResponse
+    {
+        try {
+            $this->giftCardsRepository->delete($id);
+
+            return new JsonResponse([
+                'message' => '礼品卡删除成功',
+            ]);
+        } catch (\Exception $e) {
+            return new JsonResponse([
+                'message' => '礼品卡删除失败',
+            ], 400);
+        }
+    }
+
+    /**
+     * Mass delete resources.
+     */
+    public function massDelete(): JsonResponse
+    {
+        $indices = request()->input('indices');
+
+        if (empty($indices)) {
+            return new JsonResponse([
+                'message' => '请选择要删除的礼品卡',
+            ], 422);
+        }
+
+        try {
+            foreach ($indices as $id) {
+                $this->giftCardsRepository->delete($id);
+            }
+
+            return new JsonResponse([
+                'message' => '选中的礼品卡删除成功',
+            ]);
+        } catch (\Exception $e) {
+            return new JsonResponse([
+                'message' => '批量删除失败',
+            ], 400);
+        }
+    }
+}

+ 236 - 0
packages/Longyi/Gift/src/Http/Controllers/Shop/GiftController.php

@@ -0,0 +1,236 @@
+<?php
+
+namespace Longyi\Gift\Http\Controllers\Shop;
+
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Illuminate\Http\Resources\Json\JsonResource;
+use Illuminate\Http\Response;
+use Webkul\Checkout\Facades\Cart;
+use Webkul\Shop\Http\Controllers\Controller;
+use Longyi\Gift\Repositories\GiftCardsRepository;
+use Longyi\Gift\Models\GiftCards;
+use Longyi\Gift\Http\Resources\CustomCartResource;
+
+class GiftController extends Controller
+{
+    public function __construct(
+        protected GiftCardsRepository $giftCardRepository
+    ) {
+    }
+
+    public function index()
+    {
+        echo 111;exit;
+        return view('giftcard::shop.index');
+    }
+    /**
+     * Get customer's gift cards list
+     */
+    public function getCustomerGiftCards(): JsonResponse
+    {
+        try {
+            $customerId = auth()->user()->id;
+
+            $giftCards = GiftCards::where('customer_id', $customerId)
+                ->where('remaining_giftcard_amount', '>', 0)
+                ->where('expirationdate', '>=', now())
+                ->orderBy('created_at', 'desc')
+                ->get();
+
+            return response()->json([
+                'success' => true,
+                'data' => $giftCards->map(function ($giftCard) {
+                    return [
+                        'id' => $giftCard->id,
+                        'giftcard_number' => $giftCard->giftcard_number,
+                        'giftcard_amount' => $giftCard->giftcard_amount,
+                        'remaining_giftcard_amount' => $giftCard->remaining_giftcard_amount,
+                        'formatted_remaining_giftcard_amount' => core()->formatPrice(core()->convertPrice($giftCard->remaining_giftcard_amount)),
+                        'used_giftcard_amount' => $giftCard->used_giftcard_amount,
+                        'expirationdate' => $giftCard->expirationdate->format('Y-m-d'),
+                        'giftcard_status' => $giftCard->giftcard_status,
+                    ];
+                }),
+            ]);
+        } catch (\Exception $e) {
+            return response()->json([
+                'success' => false,
+                'message' => 'Failed to fetch gift cards',
+                'error' => $e->getMessage(),
+            ], Response::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+    /**
+     * Activate Giftcard.
+     */
+    public function activateGiftCard(Request $request)
+   {
+       $customerId = auth()->user()->id;
+       try {
+           $validatedData = $this->validate($request, [
+               'giftcard_number' => 'required',
+           ]);
+           $giftCard = GiftCards::where('giftcard_number', $validatedData['giftcard_number'])
+               ->where('customer_id', $customerId)->first();
+           $cart = Cart::getCart();
+           if (!$giftCard) {
+               return (new JsonResource([
+                   'data'     => new CustomCartResource (Cart::getCart()),
+                   'message'  => trans('Coupon not found.'),
+               ]))->response()->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
+           }
+           $remainingGiftcardAmount = core()->convertPrice($giftCard->remaining_giftcard_amount);
+           if ($remainingGiftcardAmount <= 0) {
+               return (new JsonResource([
+                   'data'     => new CustomCartResource(Cart::getCart()),
+                   'message'  => trans('Gift card already used.'),
+               ]))->response()->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
+           }
+           if (!empty($cart->giftcard_amount)) {
+               return (new JsonResource([
+                   'data'     => new CustomCartResource(Cart::getCart()),
+                   'message'  => trans('The shopping cart has been paid with a gift card.'),
+               ]))->response()->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
+           }
+
+
+           $cartTotal = $cart->grand_total;
+           $remainingAmount = $remainingGiftcardAmount - $cartTotal;
+
+           if ($remainingAmount >= 0) {
+               // Apply the gift card to the cart
+               GiftCards::setGiftCardCode($giftCard);
+               Cart::collectTotals();
+
+               return response()->json([
+                   'message' => 'Gift card applied successfully',
+                   'remaining_amount' => $remainingAmount,
+                   'giftcard_number' => $giftCard->giftcard_number,
+               ]);
+           } else {
+               // Apply the gift card to the cart
+               GiftCards::setGiftCardCode($giftCard);
+               Cart::collectTotals();
+               // Gift card amount becomes zero as it's fully used
+               return response()->json([
+                   'message' => 'Gift card applied successfully',
+                   'remaining_amount' => 0,
+                   'giftcard_number' => $giftCard->giftcard_number,
+               ]);
+           }
+       } catch (\Exception $e) {
+           return (new JsonResource([
+               'data'    => new CustomCartResource(Cart::getCart()),
+               'message' => trans('error'),
+               'error' => $e->getMessage()
+           ]))->response()->setStatusCode(Response::HTTP_INTERNAL_SERVER_ERROR);
+       }
+   }
+    /**
+     * Remove applied Giftcard from the cart.
+     */
+    public function destroyGiftCard(): JsonResource
+    {
+        // Get the cart instance
+        $cart = Cart::getCart();
+
+        // Remove gift card related fields from the cart
+        GiftCards::removeGiftCardCode();
+        Cart::collectTotals();
+        // If a gift card was applied, update GiftCardBalance accordingly
+        if ($cart->giftcard_number) {
+            $giftCards = GiftCards::where('giftcard_number', $cart->giftcard_number)
+                ->where('customer_id', auth()->user()->id)
+                ->first();
+            if ($giftCards) {
+                // Reset used and remaining amounts
+                $giftCards->used_giftcard_amount = 0;
+                $giftCards->remaining_giftcard_amount = $giftCards->giftcard_amount;
+                $giftCards->save();
+            }
+        }
+
+        // Return response with updated cart data
+        return new JsonResource([
+            'data'     => new CustomCartResource(Cart::getCart()),
+            'message'  => trans('gift::app.giftcard.remove'),
+        ]);
+    }
+
+    /**
+     * Get the gift card that expires soonest (within specified days)
+     */
+    public function getExpiringSoonGiftCard(int $days = 7): ?GiftCards
+    {
+        try {
+            $customerId = auth()->user()->id;
+
+            $expiringDate = now()->addDays($days);
+
+            $giftCard = GiftCards::where('customer_id', $customerId)
+                ->where('remaining_giftcard_amount', '>', 0)
+                ->where('expirationdate', '>=', now())
+                ->where('expirationdate', '<=', $expiringDate)
+                ->orderBy('expirationdate', 'asc')
+                ->first();
+            return $giftCard;
+        } catch (\Exception $e) {
+            return null;
+        }
+    }
+
+    /**
+     * Automatically apply the soonest expiring gift card to cart
+     */
+    public function autoApplyExpiringGiftCard(): JsonResponse
+    {
+        try {
+            if (!auth()->check()) {
+                return response()->json([
+                    'success' => false,
+                    'message' => 'User not authenticated',
+                ], Response::HTTP_UNAUTHORIZED);
+            }
+            $cart = Cart::getCart();
+            if (!$cart) {
+                return response()->json([
+                    'success' => false,
+                    'message' => 'Cart not found',
+                ], Response::HTTP_NOT_FOUND);
+            }
+            if (!empty($cart->giftcard_number)) {
+                return response()->json([
+                    'success' => false,
+                    'message' => 'A gift card is already applied to the cart',
+                    'applied' => true,
+                ], Response::HTTP_OK);
+            }
+            $expiringGiftCard = $this->getExpiringSoonGiftCard(10);
+            if (!$expiringGiftCard) {
+                return response()->json([
+                    'success' => false,
+                    'message' => 'No expiring gift cards found',
+                    'applied' => false,
+                ], Response::HTTP_OK);
+            }
+            GiftCards::setGiftCardCode($expiringGiftCard);
+            Cart::collectTotals();
+            return response()->json([
+                'success' => true,
+                'message' => 'Expiring gift card applied automatically',
+                'giftcard_number' => $expiringGiftCard->giftcard_number,
+                'expiration_date' => $expiringGiftCard->expirationdate->format('Y-m-d'),
+                'remaining_amount' => $expiringGiftCard->remaining_giftcard_amount,
+                'applied' => true,
+            ], Response::HTTP_OK);
+        } catch (\Exception $e) {
+            return response()->json([
+                'success' => false,
+                'message' => 'Failed to auto-apply gift card',
+                'error' => $e->getMessage(),
+            ], Response::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+}

+ 25 - 0
packages/Longyi/Gift/src/Http/Resources/CustomCartResource.php

@@ -0,0 +1,25 @@
+<?php
+
+namespace Longyi\Gift\Http\Resources;
+
+use Webkul\Shop\Http\Resources\CartResource;
+
+class CustomCartResource extends CartResource
+{
+    /**
+     * Transform the resource into an array.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return array
+     */
+    public function toArray($request)
+    {
+        $data = parent::toArray($request);
+
+        $data['giftcard_number'] = $this->giftcard_number;
+        $data['giftcard_amount'] = $this->giftcard_amount;
+        $data['remaining_giftcard_amount'] = $this->remaining_giftcard_amount;
+
+        return $data;
+    }
+}

+ 50 - 0
packages/Longyi/Gift/src/Listeners/GiftHandler.php

@@ -0,0 +1,50 @@
+<?php
+
+namespace Longyi\Gift\Listeners;
+
+use Webkul\Paypal\Payment\SmartButton;
+use Webkul\Sales\Repositories\OrderTransactionRepository;
+use Longyi\Gift\Models\GiftCards;
+
+class GiftHandler
+{
+    /**
+     * Create a new listener instance.
+     *
+     * @return void
+     */
+    public function __construct(
+        protected SmartButton $smartButton,
+        protected OrderTransactionRepository $orderTransactionRepository
+    ) {}
+
+    /**
+     * Apply gift card to cart.
+     *
+     * @param  \Webkul\Checkout\Models\Cart  $cart
+     * @return void
+     */
+    public function applyGiftCard($cart)
+    {
+        if (!$cart || !$cart->giftcard_number) {
+            return;
+        }
+        // 获取礼品卡对象
+        $giftCard = GiftCards::where('giftcard_number', $cart->giftcard_number)->first();
+        if (!$giftCard) {
+            return;
+        }
+        $remainingAmountInCurrentCurrency = core()->convertPrice($giftCard->remaining_giftcard_amount);
+        if ($cart->grand_total <= $remainingAmountInCurrentCurrency) {
+            $cart->giftcard_amount = $cart->grand_total - 0.01;
+        } else {
+            $cart->giftcard_amount = $remainingAmountInCurrentCurrency;
+        }
+        $usableAmount = $cart->giftcard_amount;
+        $giftCardAmountInBaseCurrency = core()->convertToBasePrice($usableAmount);
+        $cart->base_giftcard_amount = $giftCardAmountInBaseCurrency;
+        $cart->grand_total = max(0, round($cart->grand_total - $usableAmount, 2));
+        $cart->base_grand_total = max(0, round($cart->base_grand_total - $giftCardAmountInBaseCurrency, 2));
+        $cart->save();
+    }
+}

+ 33 - 0
packages/Longyi/Gift/src/Listeners/InvoiceHandler.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace Longyi\Gift\Listeners;
+
+use Illuminate\Support\Facades\DB;
+
+class InvoiceHandler
+{
+    /**
+ * Handle invoice save after event.
+ *
+ * @param  \Webkul\Sales\Models\Invoice  $invoice
+ * @return void
+ */
+    public function saveAfter($invoice)
+    {
+        // 从订单中获取礼品卡信息
+        $order = $invoice->order;
+
+        if (!$order || !$order->giftcard_number || $order->giftcard_amount <= 0) {
+            return;
+        }
+
+//        // 更新发票的礼品卡信息
+//        DB::table('invoices')
+//            ->where('id', $invoice->id)
+//            ->update([
+//                'giftcard_number' => $order->giftcard_number,
+//                'giftcard_amount' => $order->giftcard_amount,
+//                'base_giftcard_amount' => $order->base_giftcard_amount,
+//            ]);
+    }
+}

+ 69 - 0
packages/Longyi/Gift/src/Listeners/OrderCancelHandler.php

@@ -0,0 +1,69 @@
+<?php
+
+namespace Longyi\Gift\Listeners;
+
+use Longyi\Gift\Models\GiftCards;
+use Illuminate\Support\Facades\Log;
+use Longyi\Gift\Models\GiftCardUsageLog;
+
+class OrderCancelHandler
+{
+    /**
+     * Handle order cancel event.
+     *
+     * @param  \Webkul\Sales\Contracts\Order  $order
+     * @return void
+     */
+    public function afterCancelOrder($order)
+    {
+        if (!$order) {
+            return;
+        }
+
+        // 检查订单是否使用了礼品卡
+        if (!$order->giftcard_number || !$order->giftcard_amount || $order->giftcard_amount <= 0) {
+            return;
+        }
+
+        $giftCardNumber = $order->giftcard_number;
+        $refundAmount = core()->convertToBasePrice($order->giftcard_amount);
+
+        // 查找礼品卡
+        $giftCard = GiftCards::where('giftcard_number', $giftCardNumber)->first();
+
+        if (!$giftCard) {
+            return;
+        }
+        // 记录退款前余额
+        $balanceBefore = $giftCard->remaining_giftcard_amount;
+        // 恢复礼品卡余额
+        $giftCard->used_giftcard_amount = max(0, $giftCard->used_giftcard_amount - $refundAmount);
+        $giftCard->remaining_giftcard_amount += $refundAmount;
+
+        // 恢复状态为未使用
+        if ($giftCard->remaining_giftcard_amount > 0) {
+            $giftCard->giftcard_status = 1; // 未使用
+        }
+
+        $giftCard->save();
+        // 记录退款日志
+        GiftCardUsageLog::create([
+            'giftcard_number' => $giftCardNumber,
+            'customer_id' => $giftCard->customer_id,
+            'order_id' => $order->id,
+            'order_increment_id' => $order->increment_id,
+            'used_amount' => $refundAmount,
+            'balance_before' => $balanceBefore,
+            'balance_after' => $giftCard->remaining_giftcard_amount,
+            'action_type' => GiftCardUsageLog::ACTION_REFUND,
+            'status' => 'completed',
+            'notes' => "order #{$order->increment_id} cancel,The balance of the gift card has been restored",
+        ]);
+//        Log::channel('giftcard')->info('订单已取消,礼品卡余额已恢复', [
+//            'order_id' => $order->id,
+//            'giftcard_number' => $giftCardNumber,
+//            'refunded_amount' => $refundAmount,
+//            'remaining_amount' => $giftCard->remaining_giftcard_amount,
+//        ]);
+    }
+}

+ 69 - 0
packages/Longyi/Gift/src/Listeners/OrderPlacedHandler.php

@@ -0,0 +1,69 @@
+<?php
+
+namespace Longyi\Gift\Listeners;
+
+use Longyi\Gift\Models\GiftCards;
+use Longyi\Gift\Models\GiftCardUsageLog;
+use Illuminate\Support\Facades\Log;
+
+class OrderPlacedHandler
+{
+    /**
+     * Handle order placed event.
+     *
+     * @param  \Webkul\Sales\Contracts\Order  $order
+     * @return void
+     */
+    public function afterPlaceOrder($order)
+    {
+        if (!$order || !$order->cart) {
+            return;
+        }
+        $cart = $order->cart;
+        // 检查订单是否使用了礼品卡
+        if (!$cart->giftcard_number || !$cart->giftcard_amount || $cart->giftcard_amount <= 0) {
+            return;
+        }
+        $giftCardNumber = $cart->giftcard_number;
+        $usedAmount = core()->convertToBasePrice($cart->giftcard_amount);
+
+        // 查找礼品卡
+        $giftCard = GiftCards::where('giftcard_number', $giftCardNumber)->first();
+        if (!$giftCard) {
+            return;
+        }
+        // 检查余额是否足够
+        if ($giftCard->remaining_giftcard_amount < $usedAmount) {
+            return;
+        }
+        // 记录使用前余额
+        $balanceBefore = $giftCard->remaining_giftcard_amount;
+        // 扣除礼品卡余额
+        $giftCard->used_giftcard_amount += $usedAmount;
+        $giftCard->remaining_giftcard_amount -= $usedAmount;
+        // 如果余额为0,标记为已使用
+        if ($giftCard->remaining_giftcard_amount <= 0) {
+            $giftCard->giftcard_status = 2; // 已使用
+        }
+        $giftCard->save();
+        // 记录使用日志
+        GiftCardUsageLog::create([
+            'giftcard_number' => $giftCardNumber,
+            'customer_id' => $giftCard->customer_id,
+            'order_id' => $order->id,
+            'order_increment_id' => $order->increment_id,
+            'used_amount' => $usedAmount,
+            'balance_before' => $balanceBefore,
+            'balance_after' => $giftCard->remaining_giftcard_amount,
+            'action_type' => GiftCardUsageLog::ACTION_USE,
+            'status' => 'completed',
+            'notes' => "order #{$order->increment_id} Use a gift card",
+        ]);
+//        \Log::channel('giftcard')->info('订单创建成功,礼品卡余额已扣除', [
+//            'order_id' => $order->id,
+//            'giftcard_number' => $giftCardNumber,
+//            'used_amount' => $usedAmount,
+//            'remaining_amount' => $giftCard->remaining_giftcard_amount,
+//        ]);
+    }
+}

+ 32 - 0
packages/Longyi/Gift/src/Listeners/OrderViewHandler.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace Longyi\Gift\Listeners;
+
+use Webkul\Sales\Models\Order;
+
+class OrderViewHandler
+{
+    /**
+     * Handle order view information discount before event.
+     *
+     * @param  array  $data
+     * @return void
+     */
+    public function renderGiftCardInfo($viewRenderEventManager)
+    {
+        // 从当前路由参数中获取订单ID
+        $orderId = request()->route('id');
+
+        if (!$orderId) {
+            return;
+        }
+
+        // 查询订单
+        $order = Order::find($orderId);
+
+        if (!$order || !$order->giftcard_number || $order->giftcard_amount <= 0) {
+            return;
+        }
+        echo view('gift::customers.account.orders.giftcard-info', compact('order'))->render();
+    }
+}

+ 94 - 0
packages/Longyi/Gift/src/Models/GiftCardUsageLog.php

@@ -0,0 +1,94 @@
+<?php
+
+namespace Longyi\Gift\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+class GiftCardUsageLog extends Model
+{
+    use HasFactory;
+
+    protected $table = 'gift_card_usage_logs';
+
+    protected $fillable = [
+        'giftcard_number',
+        'customer_id',
+        'order_id',
+        'order_increment_id',
+        'used_amount',
+        'balance_before',
+        'balance_after',
+        'action_type',
+        'status',
+        'notes',
+    ];
+
+    protected $casts = [
+        'used_amount' => 'decimal:2',
+        'balance_before' => 'decimal:2',
+        'balance_after' => 'decimal:2',
+        'customer_id' => 'integer',
+        'order_id' => 'integer',
+        'action_type' => 'integer',
+    ];
+
+    // 操作类型常量
+    const ACTION_USE = 1;      // 使用
+    const ACTION_REFUND = 2;   // 退款
+    const ACTION_ADJUST = 3;   // 手动调整
+    const ACTION_ADD = 4;   // 添加
+
+    /**
+     * 获取关联的礼品卡
+     */
+    public function giftCard(): BelongsTo
+    {
+        return $this->belongsTo(GiftCards::class, 'giftcard_number', 'giftcard_number');
+    }
+
+    /**
+     * 获取关联的订单
+     */
+    public function order(): BelongsTo
+    {
+        return $this->belongsTo(\Webkul\Sales\Models\Order::class, 'order_id');
+    }
+
+    /**
+     * 获取关联的客户
+     */
+    public function customer(): BelongsTo
+    {
+        return $this->belongsTo(\Webkul\Customer\Models\Customer::class, 'customer_id');
+    }
+
+    /**
+     * 获取操作类型文本
+     */
+    public function getActionTypeTextAttribute(): string
+    {
+        return match($this->action_type) {
+            self::ACTION_USE => '使用',
+            self::ACTION_REFUND => '退款',
+            self::ACTION_ADJUST => '手动调整',
+            self::ACTION_ADD => '添加',
+            default => '未知',
+        };
+    }
+
+    /**
+     * 获取状态文本
+     */
+    public function getStatusTextAttribute(): string
+    {
+        return match($this->status) {
+            'completed' => '已完成',
+            'pending' => '待处理',
+            'cancelled' => '已取消',
+            'refunded' => '已退款',
+            default => $this->status,
+        };
+    }
+}

+ 9 - 0
packages/Longyi/Gift/src/Models/GiftCardUsageLogProxy.php

@@ -0,0 +1,9 @@
+<?php
+
+namespace Longyi\Gift\Models;
+
+use Konekt\Concord\Proxies\ModelProxy;
+
+class GiftCardUsageLogProxy extends ModelProxy
+{
+}

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

@@ -0,0 +1,135 @@
+<?php
+
+namespace Longyi\Gift\Models;
+
+use Illuminate\Support\Str;
+use Longyi\Gift\Models\GiftCardUsageLog;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Longyi\Gift\Contracts\GiftCards as GiftCardsContract;
+
+class GiftCards extends Model implements GiftCardsContract
+{
+    use HasFactory;
+
+    protected $table = 'gift_cards';
+
+    protected $fillable = [
+        'giftcard_number',
+        'giftcard_amount',
+        'used_giftcard_amount',
+        'remaining_giftcard_amount',
+        'customer_id',
+        'channel',
+        'expirationdate',
+        'giftcard_status',
+    ];
+
+    protected $casts = [
+        'giftcard_amount' => 'decimal:2',
+        'used_giftcard_amount' => 'decimal:2',
+        'remaining_giftcard_amount' => 'decimal:2',
+        'customer_id' => 'integer',
+        'expirationdate' => 'date',
+        'channel' => 'string',
+    ];
+    protected static function boot()
+    {
+        parent::boot();
+
+        static::created(function ($giftCard) {
+
+            GiftCardUsageLog::create([
+                'giftcard_number' => $giftCard->giftcard_number,
+                'customer_id' => $giftCard->customer_id,
+                'balance_before' => $giftCard->giftcard_amount,
+                'action_type' => GiftCardUsageLog::ACTION_ADD,
+                'notes' => $giftCard->notes
+            ]);
+        });
+    }
+    public static function setGiftCardCode($giftcard)
+    {
+        if (!$giftcard) {
+            return false;
+        }
+        $cart = cart()->getCart();
+        $cart->giftcard_number = $giftcard->giftcard_number;
+        $cart->save();
+        return true;
+    }
+
+    public static function removeGiftCardCode()
+    {
+        $cart = cart()->getCart();
+        $giftcardAmount = $cart->giftcard_amount ?? 0;
+
+        // 清除购物车中的礼品卡标记
+        $cart->giftcard_number = null;
+        $cart->giftcard_amount = null;
+
+        // 恢复购物车总额
+        $cart->grand_total += $giftcardAmount;
+        $cart->base_grand_total += $giftcardAmount;
+
+        $cart->save();
+
+        return true;
+    }
+    /**
+     * Add a new gift card for a customer.
+     *
+     * @param  int  $customerId  The ID of the customer to receive the gift card.
+     * @param  float  $giftcardAmount  The initial amount/balance of the gift card.
+     * @param  int  $num  Maximum number of gift cards allowed for this channel (0 means unlimited).
+     * @param  int  $expirationdate  Number of days until the gift card expires (default: 30 days).
+     * @param  string  $channel  The source channel or campaign identifier (default: 'activity_26_04_21').
+     * @param  string  $notes  Additional notes or description for the gift card creation.
+     * @return void
+     * @throws \Exception If customerId is empty, giftcardAmount is empty, or gift card limit exceeded.
+     */
+    public static function addGiftCard($customerId, $giftcardAmount, $num = 0, $expirationdate = 30, $channel = 'activity_26_04_21', $notes = '拉新活动添加')
+    {
+        if (empty($customerId)) {
+            throw new \Exception('customerId is empty');
+        }
+        if (empty($giftcardAmount)) {
+            throw new \Exception('giftcardAmount is empty');
+        }
+        $data = GiftCards::where('customer_id', $customerId)
+            ->where('channel', $channel)
+            ->count();
+        if ($num != 0) {
+            $giftNum =count($data);
+            if($giftNum <= $num){
+                throw new \Exception('gift num is not enough');
+            }
+        }
+        static::create([
+            'giftcard_number' => self::generateGiftCardNumber(),
+            'giftcard_amount' => $giftcardAmount,
+            'used_giftcard_amount' => 0,
+            'remaining_giftcard_amount' => $giftcardAmount,
+            'customer_id' => $customerId,
+            'expirationdate' => now()->addDays($expirationdate),
+            'giftcard_status' => 1,
+            'channel' => $channel,
+            'notes' => $notes
+        ]);
+    }
+    public static function generateGiftCardNumber()
+    {
+        do {
+            // Generate a random string of characters
+            $randomString = Str::upper(Str::random(16));
+
+            // Format the string into groups of four separated by dashes
+            $code = implode('-', str_split($randomString, 4));
+
+            // Check if the code already exists in the database
+            $exists = GiftCards::where('giftcard_number', $code)->exists();
+        } while ($exists);
+
+        return $code;
+    }
+}

+ 9 - 0
packages/Longyi/Gift/src/Models/GiftCardsProxy.php

@@ -0,0 +1,9 @@
+<?php
+
+namespace Longyi\Gift\Models;
+
+use Konekt\Concord\Proxies\ModelProxy;
+
+class GiftCardsProxy extends ModelProxy
+{
+}

+ 26 - 0
packages/Longyi/Gift/src/Providers/EventServiceProvider.php

@@ -0,0 +1,26 @@
+<?php
+
+namespace Longyi\Gift\Providers;
+
+use Illuminate\Support\Facades\Event;
+use Illuminate\Support\ServiceProvider;
+
+class EventServiceProvider extends ServiceProvider
+{
+    /**
+     * Bootstrap services.
+     *
+     * @return void
+     */
+    public function boot()
+    {
+        Event::listen('checkout.cart.collect.totals.after', 'Longyi\Gift\Listeners\GiftHandler@applyGiftCard');
+        Event::listen('checkout.order.save.after', 'Longyi\Gift\Listeners\OrderPlacedHandler@afterPlaceOrder');
+        Event::listen('bagisto.shop.checkout.cart.summary.member_discount.after', function($viewRenderEventManager) {
+            $viewRenderEventManager->addTemplate('gift::components.giftcard-cartsummary');
+        });
+        Event::listen('bagisto.shop.customers.account.orders.view.information.discount.after', 'Longyi\Gift\Listeners\OrderViewHandler@renderGiftCardInfo');
+        Event::listen('sales.invoice.save.after', 'Longyi\Gift\Listeners\InvoiceHandler@saveAfter');
+        Event::listen('sales.order.cancel.after', 'Longyi\Gift\Listeners\OrderCancelHandler@afterCancelOrder');
+    }
+}

+ 64 - 0
packages/Longyi/Gift/src/Providers/GiftServiceProvider.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace Longyi\Gift\Providers;
+
+use Longyi\Gift\Repositories\CustomInvoiceRepository;
+use Illuminate\Support\ServiceProvider;
+use Illuminate\Support\Facades\Event;
+use Longyi\Gift\Providers\EventServiceProvider;
+use Longyi\Gift\Repositories\GiftCardsRepository;
+use Webkul\Sales\Repositories\InvoiceRepository;
+use Webkul\Sales\Repositories\OrderRepository;
+use Longyi\Gift\Repositories\CustomOrderRepository;
+
+class GiftServiceProvider extends ServiceProvider
+{
+    /**
+     * Register services.
+     */
+    public function register(): void
+    {
+        $this->app->bind(InvoiceRepository::class, CustomInvoiceRepository::class);
+        $this->app->bind(OrderRepository::class, CustomOrderRepository::class);
+        $this->registerConfig();
+    }
+
+    /**
+     * Bootstrap services.
+     */
+    public function boot(): void
+    {
+        $this->loadMigrationsFrom(__DIR__ . '/../Database/Migrations');
+
+        $this->loadRoutesFrom(__DIR__ . '/../Routes/admin-routes.php');
+
+        $this->loadRoutesFrom(__DIR__ . '/../Routes/shop-routes.php');
+
+        $this->loadTranslationsFrom(__DIR__ . '/../Resources/lang', 'gift');
+
+        $this->loadViewsFrom(__DIR__ . '/../Resources/views', 'gift');
+
+        $this->app['view']->prependNamespace('admin', __DIR__.'/../Resources/views');
+
+        Event::listen('bagisto.admin.layout.head', function($viewRenderEventManager) {
+            $viewRenderEventManager->addTemplate('gift::admin.layouts.style');
+        });
+        $this->app->register(EventServiceProvider::class);
+    }
+
+    /**
+     * Register package config.
+     *
+     * @return void
+     */
+    protected function registerConfig()
+    {
+        $this->mergeConfigFrom(
+            dirname(__DIR__) . '/Config/admin-menu.php', 'menu.admin'
+        );
+
+        $this->mergeConfigFrom(
+            dirname(__DIR__) . '/Config/acl.php', 'acl'
+        );
+    }
+}

+ 15 - 0
packages/Longyi/Gift/src/Providers/ModuleServiceProvider.php

@@ -0,0 +1,15 @@
+<?php
+
+namespace Longyi\Gift\Providers;
+
+use Konekt\Concord\BaseModuleServiceProvider;
+
+class ModuleServiceProvider extends BaseModuleServiceProvider
+{
+    /**
+     * Models.
+     *
+     * @var array
+     */
+    protected $models = [];
+}

+ 94 - 0
packages/Longyi/Gift/src/Repositories/CustomInvoiceRepository.php

@@ -0,0 +1,94 @@
+<?php
+
+namespace Longyi\Gift\Repositories;
+
+use Illuminate\Support\Facades\Event;
+use Webkul\Sales\Repositories\InvoiceRepository as BaseInvoiceRepository;
+
+class CustomInvoiceRepository extends BaseInvoiceRepository
+{
+    public function collectTotals($invoice)
+    {
+        $invoice->sub_total = $invoice->base_sub_total = 0;
+        $invoice->sub_total_incl_tax = $invoice->base_sub_total_incl_tax = 0;
+        $invoice->tax_amount = $invoice->base_tax_amount = 0;
+        $invoice->shipping_tax_amount = $invoice->shipping_tax_amount = 0;
+        $invoice->discount_amount = $invoice->base_discount_amount = 0;
+        $invoice->giftcard_amount = $invoice->giftcard_amount = 0;
+        $invoice->base_giftcard_amount = $invoice->base_giftcard_amount = 0;
+        $invoice->vip_plus_amount = $invoice->base_vip_plus_amount = 0;
+        $invoice->vip_discount_amount = $invoice->base_vip_discount_amount = 0;
+
+        foreach ($invoice->items as $item) {
+            $invoice->tax_amount += $item->tax_amount;
+            $invoice->base_tax_amount += $item->base_tax_amount;
+
+            $invoice->discount_amount += $item->discount_amount;
+            $invoice->base_discount_amount += $item->base_discount_amount;
+
+            $invoice->sub_total += $item->total;
+            $invoice->base_sub_total += $item->base_total;
+
+            $invoice->sub_total_incl_tax = (float) $invoice->sub_total_incl_tax + $item->total_incl_tax;
+            $invoice->base_sub_total_incl_tax = (float) $invoice->base_sub_total_incl_tax + $item->base_total_incl_tax;
+        }
+
+        $invoice->shipping_amount = $invoice->order->shipping_amount;
+        $invoice->shipping_amount_incl_tax = $invoice->order->shipping_amount_incl_tax;
+
+        $invoice->base_shipping_amount = $invoice->order->base_shipping_amount;
+        $invoice->base_shipping_amount_incl_tax = $invoice->order->base_shipping_amount_incl_tax;
+
+        $invoice->discount_amount += $invoice->order->shipping_discount_amount;
+        $invoice->base_discount_amount += $invoice->order->base_shipping_discount_amount;
+
+        $invoice->giftcard_amount += $invoice->order->giftcard_amount;
+        $invoice->base_giftcard_amount += $invoice->order->base_giftcard_amount;
+        $invoice->giftcard_number = $invoice->order->giftcard_number;
+
+        $invoice->vip_plus_amount += $invoice->order->vip_plus_amount;
+        $invoice->base_vip_plus_amount += $invoice->order->base_vip_plus_amount;
+        $invoice->vip_discount_amount += $invoice->order->vip_discount_amount;
+        $invoice->base_vip_discount_amount += $invoice->order->base_vip_discount_amount;
+
+        if ($invoice->order->shipping_tax_amount) {
+            $invoice->shipping_tax_amount = $invoice->order->shipping_tax_amount;
+
+            $invoice->base_shipping_tax_amount = $invoice->order->base_shipping_tax_amount;
+
+            $invoice->tax_amount += $invoice->order->shipping_tax_amount;
+
+            $invoice->base_tax_amount += $invoice->order->base_shipping_tax_amount;
+
+            foreach ($invoice->order->invoices as $prevInvoice) {
+                if ((float) $prevInvoice->shipping_tax_amount) {
+                    $invoice->shipping_tax_amount = $invoice->base_shipping_tax_amount = 0;
+
+                    $invoice->tax_amount -= $invoice->order->shipping_tax_amount;
+
+                    $invoice->base_tax_amount -= $invoice->order->base_shipping_tax_amount;
+                }
+            }
+        }
+
+        if ($invoice->order->shipping_amount) {
+            foreach ($invoice->order->invoices as $prevInvoice) {
+                if ((float) $prevInvoice->shipping_amount) {
+                    $invoice->shipping_amount = $invoice->base_shipping_amount = 0;
+                    $invoice->shipping_amount_incl_tax = $invoice->base_shipping_amount_incl_tax = 0;
+                }
+
+                if ($prevInvoice->id != $invoice->id) {
+                    $invoice->discount_amount -= $invoice->order->shipping_discount_amount;
+                    $invoice->base_discount_amount -= $invoice->order->base_shipping_discount_amount;
+                }
+            }
+        }
+
+        $invoice->grand_total = $invoice->sub_total + $invoice->tax_amount + $invoice->shipping_amount - $invoice->discount_amount - $invoice->giftcard_amount + $invoice->vip_plus_amount - $invoice->vip_discount_amount;
+        $invoice->base_grand_total = $invoice->base_sub_total + $invoice->base_tax_amount + $invoice->base_shipping_amount - $invoice->base_discount_amount - $invoice->base_giftcard_amount + $invoice->base_vip_plus_amount - $invoice->base_vip_discount_amount;
+
+        $invoice->save();
+        return $invoice;
+    }
+}

+ 77 - 0
packages/Longyi/Gift/src/Repositories/CustomOrderRepository.php

@@ -0,0 +1,77 @@
+<?php
+
+namespace Longyi\Gift\Repositories;
+
+use Webkul\Sales\Repositories\OrderRepository as BaseOrderRepository;
+
+class CustomOrderRepository extends BaseOrderRepository
+{
+     public function collectTotals($order)
+    {
+        // order invoice total
+        $order->sub_total_invoiced = $order->base_sub_total_invoiced = 0;
+        $order->shipping_invoiced = $order->base_shipping_invoiced = 0;
+        $order->tax_amount_invoiced = $order->base_tax_amount_invoiced = 0;
+        $order->discount_invoiced = $order->base_discount_invoiced = 0;
+        $order->giftcard_amount = $order->base_giftcard_amount = 0;
+        $order->vip_plus_amount = $order->base_vip_plus_amount = 0;
+        $order->vip_discount_amount = $order->base_vip_discount_amount = 0;
+
+        foreach ($order->invoices as $invoice) {
+            $order->sub_total_invoiced += $invoice->sub_total;
+            $order->base_sub_total_invoiced += $invoice->base_sub_total;
+
+            $order->shipping_invoiced += $invoice->shipping_amount;
+            $order->base_shipping_invoiced += $invoice->base_shipping_amount;
+
+            $order->tax_amount_invoiced += $invoice->tax_amount;
+            $order->base_tax_amount_invoiced += $invoice->base_tax_amount;
+
+            $order->discount_invoiced += $invoice->discount_amount;
+            $order->base_discount_invoiced += $invoice->base_discount_amount;
+
+            $order->giftcard_amount += $invoice->giftcard_amount;
+            $order->base_giftcard_amount += $invoice->base_giftcard_amount;
+
+            $order->vip_plus_amount += $invoice->vip_plus_amount;
+            $order->base_vip_plus_amount += $invoice->base_vip_plus_amount;
+
+            $order->vip_discount_amount += $invoice->vip_discount_amount;
+            $order->base_vip_discount_amount += $invoice->base_vip_discount_amount;
+        }
+
+        $order->grand_total_invoiced = $order->sub_total_invoiced + $order->shipping_invoiced + $order->tax_amount_invoiced - $order->discount_invoiced - $order->giftcard_amount + $order->vip_plus_amount - $order->vip_discount_amount;
+        $order->base_grand_total_invoiced = $order->base_sub_total_invoiced + $order->base_shipping_invoiced + $order->base_tax_amount_invoiced - $order->base_discount_invoiced - $order->base_giftcard_amount + $order->base_vip_plus_amount - $order->base_vip_discount_amount;
+
+        // order refund total
+        $order->sub_total_refunded = $order->base_sub_total_refunded = 0;
+        $order->shipping_refunded = $order->base_shipping_refunded = 0;
+        $order->tax_amount_refunded = $order->base_tax_amount_refunded = 0;
+        $order->discount_refunded = $order->base_discount_refunded = 0;
+        $order->grand_total_refunded = $order->base_grand_total_refunded = 0;
+
+        foreach ($order->refunds as $refund) {
+            $order->sub_total_refunded += $refund->sub_total;
+            $order->base_sub_total_refunded += $refund->base_sub_total;
+
+            $order->shipping_refunded += $refund->shipping_amount;
+            $order->base_shipping_refunded += $refund->base_shipping_amount;
+
+            $order->tax_amount_refunded += $refund->tax_amount;
+            $order->base_tax_amount_refunded += $refund->base_tax_amount;
+
+            $order->discount_refunded += $refund->discount_amount;
+            $order->base_discount_refunded += $refund->base_discount_amount;
+
+            $order->grand_total_refunded += $refund->adjustment_refund - $refund->adjustment_fee;
+            $order->base_grand_total_refunded += $refund->base_adjustment_refund - $refund->base_adjustment_fee;
+        }
+
+        $order->grand_total_refunded += $order->sub_total_refunded + $order->shipping_refunded + $order->tax_amount_refunded - $order->discount_refunded;
+        $order->base_grand_total_refunded += $order->base_sub_total_refunded + $order->base_shipping_refunded + $order->base_tax_amount_refunded - $order->base_discount_refunded;
+
+        $order->save();
+
+        return $order;
+    }
+}

+ 16 - 0
packages/Longyi/Gift/src/Repositories/GiftCardsRepository.php

@@ -0,0 +1,16 @@
+<?php
+
+namespace Longyi\Gift\Repositories;
+
+use Webkul\Core\Eloquent\Repository;
+
+class GiftCardsRepository extends Repository
+{
+    /**
+     * Specify model class name.
+     */
+    public function model(): string
+    {
+        return 'Longyi\Gift\Models\GiftCards';
+    }
+}

+ 23 - 0
packages/Longyi/Gift/src/Resources/assets/css/app.css

@@ -0,0 +1,23 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer components {
+    .temp-icon {
+        width: 48px;
+        height: 48px;
+        display: inline-block;
+        background-size: cover;
+        background-image: url("../images/icon-temp.svg");
+    }
+
+    .active {
+        .temp-icon {
+            background-image: url("../images/icon-temp-active.svg");
+        }
+
+        &.temp-icon {
+            background-image: url("../images/icon-temp-active.svg");
+        }
+    }
+}

+ 12 - 0
packages/Longyi/Gift/src/Resources/assets/images/icon-temp-active.svg

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="48px" height="48px" viewBox="0 0 48 48" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
+    <title>Icon-Catalog-Active</title>
+    <desc>Created with Sketch.</desc>
+    <defs></defs>
+    <g id="Icon-Catalog-Active" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
+        <g transform="translate(9.000000, 7.000000)" stroke="#0041FF" stroke-width="2">
+            <rect id="Rectangle-2" x="0" y="0" width="30" height="34"></rect>
+        </g>
+    </g>
+</svg>

+ 12 - 0
packages/Longyi/Gift/src/Resources/assets/images/icon-temp.svg

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="48px" height="48px" viewBox="0 0 48 48" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
+    <title>Icon-Catalog</title>
+    <desc>Created with Sketch.</desc>
+    <defs></defs>
+    <g id="Icon-Catalog" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
+        <g transform="translate(9.000000, 7.000000)" stroke-width="2">
+            <rect id="Rectangle-2" stroke="#8E8E8E" x="0" y="0" width="30" height="34"></rect>
+        </g>
+    </g>
+</svg>

+ 4 - 0
packages/Longyi/Gift/src/Resources/assets/js/app.js

@@ -0,0 +1,4 @@
+/**
+ * This will track all the images and fonts for publishing.
+ */
+import.meta.glob(["../images/**"]);

+ 52 - 0
packages/Longyi/Gift/src/Resources/lang/en/app.php

@@ -0,0 +1,52 @@
+<?php
+
+return [
+    'admin' => [
+        'title' => '礼品卡管理',
+
+        'datagrid' => [
+            'id' => 'ID',
+            'giftcard-number' => '礼品卡号',
+            'giftcard-amount' => '卡金额',
+            'used-amount' => '已用金额',
+            'remaining-amount' => '剩余金额',
+            'customer-id' => '客户 ID',
+            'expiration-date' => '过期日期',
+            'status' => '状态',
+            'created-at' => '创建时间',
+            'edit' => '编辑',
+            'delete' => '删除',
+            'delete-success' => '选中的礼品卡删除成功',
+        ],
+
+        'create' => [
+            'title' => '创建礼品卡',
+            'save-btn' => '保存',
+            'back-btn' => '返回',
+            'general' => '常规信息',
+            'giftcard-number' => '礼品卡号',
+            'giftcard-amount' => '卡金额',
+            'customer-id' => '客户 ID',
+            'expirationdate' => '过期日期',
+            'giftcard-status' => '状态',
+            'unused' => '未使用',
+            'used' => '已使用',
+        ],
+
+        'edit' => [
+            'title' => '编辑礼品卡',
+            'save-btn' => '保存',
+            'back-btn' => '返回',
+        ],
+    ],
+    'giftcard' => [
+        'giftcard_amount' => 'Giftcard Amount',
+        'applied'   =>  'Giftcard Applied',
+        'discount'   =>  'Giftcard Discount',
+        'apply'   =>  'Apply Giftcard',
+        'enter-your-code' => 'Enter your code',
+        'remove'          => 'Remove Giftcard',
+        'remaining_giftcard_amount' => 'Remaining Amount',
+        'giftcard_number'      => 'Giftcard Number',
+    ]
+];

+ 52 - 0
packages/Longyi/Gift/src/Resources/lang/zh_CN/app.php

@@ -0,0 +1,52 @@
+<?php
+
+return [
+    'admin' => [
+        'title' => '礼品卡管理',
+
+        'datagrid' => [
+            'id' => 'ID',
+            'giftcard-number' => '礼品卡号',
+            'giftcard-amount' => '卡金额',
+            'used-amount' => '已用金额',
+            'remaining-amount' => '剩余金额',
+            'customer-id' => '客户 ID',
+            'expiration-date' => '过期日期',
+            'status' => '状态',
+            'created-at' => '创建时间',
+            'edit' => '编辑',
+            'delete' => '删除',
+            'delete-success' => '选中的礼品卡删除成功',
+        ],
+
+        'create' => [
+            'title' => '创建礼品卡',
+            'save-btn' => '保存',
+            'back-btn' => '返回',
+            'general' => '常规信息',
+            'giftcard-number' => '礼品卡号',
+            'giftcard-amount' => '卡金额',
+            'customer-id' => '客户 ID',
+            'expirationdate' => '过期日期',
+            'giftcard-status' => '状态',
+            'unused' => '未使用',
+            'used' => '已使用',
+        ],
+
+        'edit' => [
+            'title' => '编辑礼品卡',
+            'save-btn' => '保存',
+            'back-btn' => '返回',
+        ],
+    ],
+    'giftcard' => [
+        'giftcard_amount' => 'Giftcard Amount',
+        'applied'   =>  'Giftcard Applied',
+        'discount'   =>  'Giftcard Discount',
+        'apply'   =>  'Apply Giftcard',
+        'enter-your-code' => 'Enter your code',
+        'remove'          => 'Remove Giftcard',
+        'remaining_giftcard_amount' => 'Remaining Amount',
+        'giftcard_number'      => 'Giftcard Number',
+    ],
+];

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

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

+ 25 - 0
packages/Longyi/Gift/src/Resources/views/admin/index.blade.php

@@ -0,0 +1,25 @@
+<x-admin::layouts>
+    <x-slot:title>
+        礼品卡管理
+    </x-slot>
+
+    @if(session('success'))
+        <div class="mb-4 px-4 py-3 bg-green-500 text-white rounded-lg">
+            {{ session('success') }}
+        </div>
+    @endif
+
+    <div class="flex justify-between items-center">
+        <p class="text-xl text-gray-800 font-bold">
+            礼品卡列表
+        </p>
+
+        <a href="{{ route('admin.gift.create') }}">
+            <button class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
+                添加礼品卡
+            </button>
+        </a>
+    </div>
+
+    <x-admin::datagrid src="{{ route('admin.gift.index') }}"></x-admin::datagrid>
+</x-admin::layouts>

+ 1 - 0
packages/Longyi/Gift/src/Resources/views/admin/layouts/style.blade.php

@@ -0,0 +1 @@
+<link rel="stylesheet" href="{{ asset('themes/default/assets/css/admin.css') }}">

+ 309 - 0
packages/Longyi/Gift/src/Resources/views/components/giftcard-cartsummary.blade.php

@@ -0,0 +1,309 @@
+{!! view_render_event('bagisto.shop.checkout.cart.summary.giftcard.before') !!}
+
+<v-gift-card
+    :cart="cart"
+    @giftcard-applied="getCart"
+    @giftcard-removed="getCart"
+>
+</v-gift-card>
+@pushOnce('scripts')
+    <script
+        type="text/x-template"
+        id="v-gift-card-template"
+    >
+        <div class="flex justify-between text-right">
+            <p class="text-base max-sm:text-sm max-sm:font-normal">
+                @{{ cart.giftcard_number ? ("@lang('gift::app.giftcard.applied')") : "@lang('gift::app.giftcard.discount')" }}
+            </p>
+
+            {!! view_render_event('bagisto.shop.checkout.cart.gift.before') !!}
+
+            <p class="text-base font-medium max-sm:text-sm">
+                <!-- Apply Giftcard modal -->
+                <x-shop::modal ref="giftCardModel">
+                    <!-- Modal Toggler -->
+                    <x-slot:toggle>
+                        <span
+                            class="text-[#0A49A7] cursor-pointer"
+                            role="button"
+                            tabindex="0"
+                            v-if="!cart || !cart.giftcard_number"
+                            @click="loadGiftCards"
+                        >
+                            @lang('gift::app.giftcard.apply')
+                        </span>
+                        <span
+                            v-else
+                            class="text-green-600 font-semibold"
+                        >
+                            @{{ cart.giftcard_number }}
+                        </span>
+                        </x-slot>
+
+                        <!-- Modal Header -->
+                        <x-slot:header>
+                            <h2 class="text-2xl font-medium max-sm:text-xl">
+                                @lang('gift::app.giftcard.apply')
+                            </h2>
+                            </x-slot>
+
+                            <!-- Modal Content -->
+                            <x-slot:content>
+                                <!-- Loading State -->
+                                <div v-if="loading" class="text-center py-4">
+                                    <p class="text-gray-500">加载中...</p>
+                                </div>
+
+                                <!-- Gift Card List -->
+                                <div v-else-if="giftCards.length > 0" class="space-y-3 max-h-96 overflow-y-auto">
+                                    <div
+                                        v-for="giftCard in giftCards"
+                                        :key="giftCard.id"
+                                        class="border rounded-lg p-4 hover:bg-gray-50 cursor-pointer transition-colors"
+                                        @click="selectGiftCard(giftCard)"
+                                        :class="{'bg-blue-50 border-blue-500': giftCardNumber === giftCard.giftcard_number}"
+                                    >
+                                        <div class="flex justify-between items-start">
+                                            <div class="flex-1">
+                                                <p class="font-semibold text-lg text-gray-800">
+                                                    @{{ giftCard.giftcard_number }}
+                                                </p>
+                                                <p class="text-sm text-gray-600 mt-1">
+                                                    余额: <span class="font-bold text-green-600">@{{ giftCard.formatted_remaining_giftcard_amount }}</span>
+                                                </p>
+                                                <p class="text-xs text-gray-500 mt-1">
+                                                    有效期至: @{{ giftCard.expirationdate }}
+                                                </p>
+                                            </div>
+                                            <button
+                                                type="button"
+                                                class="px-4 py-2 bg-blue-600 text-blue-700 rounded-lg hover:bg-blue-700 text-sm"
+                                                @click.stop="applySelectedGiftCard(giftCard)"
+                                            >
+                                                使用
+                                            </button>
+                                        </div>
+                                    </div>
+                                </div>
+
+                                <!-- No Gift Cards -->
+                                <div v-else class="text-center py-8">
+                                    <p class="text-gray-500">您还没有可用的礼品卡</p>
+                                </div>
+                                </x-slot>
+                </x-shop::modal>
+
+                <!-- Applied Coupon Information Container -->
+            <div
+                class="flex justify-between items-center text-xs font-small ml-4"
+                v-if="cart && cart.giftcard_number"
+            >
+                    <span
+                        class="icon-cancel text-2xl cursor-pointer text-red-500 hover:text-red-700"
+                        title="@lang('gift::app.giftcard.remove')"
+                        @click="destroyGiftCard"
+                    >
+                    </span>
+            </div>
+            </p>
+
+            {!! view_render_event('bagisto.shop.checkout.cart.giftcard.after') !!}
+        </div>
+    </script>
+
+    <script type="module">
+        app.component('v-gift-card', {
+            template: '#v-gift-card-template',
+
+            props: ['cart'],
+
+            data() {
+                return {
+                    isStoring: false,
+                    loading: false,
+                    giftCards: [],
+                    giftCardNumber: ''
+                };
+            },
+
+            mounted() {
+                this.autoApplyExpiringGiftCard();
+            },
+
+            methods: {
+                // 自动应用即将过期的礼品卡
+                autoApplyExpiringGiftCard() {
+                    console.log('Checking for expiring gift cards...');
+                    this.$axios.post("{{ route('shop.api.checkout.cart.giftcard.auto-apply') }}", {
+                        _token: "{{ csrf_token() }}"
+                    })
+                        .then((response) => {
+                            console.log('Auto-apply response:', response.data);
+                            if (response.data.success && response.data.applied) {
+                                this.$emitter.emit('add-flash', {
+                                    type: 'success',
+                                    message: `已自动应用即将过期的礼品卡: ${response.data.giftcard_number} (有效期至: ${response.data.expiration_date})`
+                                });
+                                this.$emit('giftcard-applied', response.data);
+                            } else if (response.data.applied) {
+                                console.log('Gift card already applied');
+                            } else {
+                                console.log('No expiring gift cards found');
+                            }
+                        })
+                        .catch((error) => {
+                            console.error('Auto-apply gift card error:', error);
+                        });
+                },
+
+                // 加载用户的礼品卡列表
+                loadGiftCards() {
+                    console.log('Loading gift cards...');
+                    this.loading = true;
+                    this.$axios.get("{{ route('shop.api.checkout.cart.giftcard.list') }}")
+                        .then((response) => {
+                            console.log('Gift cards response:', response.data);
+                            this.loading = false;
+                            if (response.data.success) {
+                                this.giftCards = response.data.data;
+                                console.log('Gift cards loaded:', this.giftCards.length);
+                            } else {
+                                console.error('Failed to load gift cards:', response.data.message);
+                                this.$emitter.emit('add-flash', {
+                                    type: 'error',
+                                    message: response.data.message || '加载礼品卡失败'
+                                });
+                            }
+                        })
+                        .catch((error) => {
+                            console.error('Failed to load gift cards:', error);
+                            this.loading = false;
+                            this.$emitter.emit('add-flash', {
+                                type: 'error',
+                                message: '加载礼品卡失败,请重试'
+                            });
+                        });
+                },
+
+                // 选择礼品卡(高亮显示)
+                selectGiftCard(giftCard) {
+                    this.giftCardNumber = giftCard.giftcard_number;
+                    console.log('Selected gift card:', this.giftCardNumber);
+                },
+
+                // 应用选中的礼品卡
+                applySelectedGiftCard(giftCard) {
+                    console.log('Applying gift card:', giftCard.giftcard_number);
+                    this.giftCardNumber = giftCard.giftcard_number;
+                    this.activateGiftCard();
+                },
+
+                activateGiftCard() {
+                    if (!this.giftCardNumber) {
+                        this.$emitter.emit('add-flash', {
+                            type: 'warning',
+                            message: '请选择一个礼品卡'
+                        });
+                        return;
+                    }
+
+                    this.isStoring = true;
+                    this.$axios.post("{{ route('shop.api.checkout.cart.giftcard.activate') }}", {
+                        giftcard_number: this.giftCardNumber,
+                        _token: "{{ csrf_token() }}"
+                    })
+                        .then((response) => {
+                            console.log('Gift card activated:', response.data);
+                            this.isStoring = false;
+                            this.$refs.giftCardModel.toggle();
+
+                            // 发送事件通知父组件刷新
+                            this.$emit('giftcard-applied', response.data);
+
+                            this.$emitter.emit('add-flash', {
+                                type: 'success',
+                                message: response.data.message || '礼品卡应用成功'
+                            });
+                            this.resetForm();
+                        })
+                        .catch((error) => {
+                            console.error('Activate gift card error:', error);
+                            this.isStoring = false;
+                            this.$refs.giftCardModel.toggle();
+                            this.$emitter.emit('add-flash', {
+                                type: 'error',
+                                message: error.response?.data?.message || '应用礼品卡失败'
+                            });
+                        });
+                },
+
+                resetForm() {
+                    this.giftCardNumber = '';
+                    this.giftCards = [];
+                },
+
+                destroyGiftCard() {
+                    console.log('Removing gift card...');
+                    this.$axios.delete("{{ route('shop.api.checkout.cart.giftcard.remove') }}", {
+                        headers: {
+                            'X-CSRF-TOKEN': "{{ csrf_token() }}"
+                        }
+                    })
+                        .then((response) => {
+                            console.log('Gift card removed:', response.data);
+                            // Reset form data
+                            this.resetForm();
+
+                            // 发送事件通知父组件刷新
+                            this.$emit('giftcard-removed');
+
+                            this.$emitter.emit('add-flash', {
+                                type: 'success',
+                                message: response.data.message || '礼品卡已移除'
+                            });
+                        })
+                        .catch(error => {
+                            console.error('Remove gift card error:', error);
+                            this.$emitter.emit('add-flash', {
+                                type: 'error',
+                                message: error.response?.data?.message || '移除礼品卡失败'
+                            });
+                        });
+                },
+            }
+        })
+    </script>
+
+@endPushOnce
+{!! view_render_event('bagisto.shop.checkout.cart.summary.giftcard.after') !!}
+
+<!-- Giftcard Discount -->
+{!! view_render_event('bagisto.shop.checkout.onepage.summary.giftcard_amount.before') !!}
+<div
+    class="flex text-right justify-between mt-2"
+    v-if="cart.giftcard_amount && parseFloat(cart.giftcard_amount) > 0"
+>
+    <p class="text-base">
+        @lang('gift::app.giftcard.giftcard_amount')
+    </p>
+
+    <p class="text-base font-medium">
+        - @{{ cart.formatted_giftcard_amount }}
+    </p>
+</div>
+
+{{--<div--}}
+{{--    class="flex text-right justify-between"--}}
+{{--    v-if="cart.giftcard_amount && parseFloat(cart.giftcard_amount) > 0"--}}
+{{-->--}}
+{{--    <p class="text-base">--}}
+{{--        @lang('gift::app.giftcard.remaining_giftcard_amount')--}}
+{{--    </p>--}}
+
+{{--    <p class="text-base font-medium">--}}
+{{--        $@{{ cart.remaining_giftcard_amount }}--}}
+{{--    </p>--}}
+{{--</div>--}}
+{!! view_render_event('bagisto.shop.checkout.onepage.summary.giftcard_amount.after') !!}
+
+

+ 15 - 0
packages/Longyi/Gift/src/Resources/views/customers/account/orders/giftcard-info.blade.php

@@ -0,0 +1,15 @@
+@if (isset($order) && $order->giftcard_number && $order->giftcard_amount > 0)
+    <div class="flex w-full justify-between gap-x-5">
+        <p>
+            @lang('gift::app.giftcard.applied')
+
+            @if ($order->giftcard_number)
+                ({{ $order->giftcard_number }})
+            @endif
+        </p>
+
+        <p>
+          - {{ core()->formatPrice($order->giftcard_amount, $order->order_currency_code) }}
+        </p>
+    </div>
+@endif

+ 533 - 0
packages/Longyi/Gift/src/Resources/views/sales/invoices/view.blade.php

@@ -0,0 +1,533 @@
+<x-admin::layouts>
+    <x-slot:title>
+        @lang('admin::app.sales.invoices.view.title', ['invoice_id' => $invoice->increment_id ?? $invoice->id])
+    </x-slot>
+
+    @php
+        $order = $invoice->order;
+    @endphp
+
+        <!-- Main Body -->
+    <div class="grid">
+        <div class="flex items-center justify-between gap-4 max-sm:flex-wrap">
+            {!! view_render_event('bagisto.admin.sales.invoice.title.before', ['order' => $order]) !!}
+
+            <p class="text-xl font-bold leading-6 text-gray-800 dark:text-white">
+                @lang('admin::app.sales.invoices.view.title', ['invoice_id' => $invoice->increment_id ?? $invoice->id])
+
+                <span class="{{ $invoice->status_label_class }} mx-1.5 text-sm">
+                    {{ $invoice->status_label }}
+                </span>
+            </p>
+
+            {!! view_render_event('bagisto.admin.sales.invoice.title.after', ['order' => $order]) !!}
+
+            <div class="flex items-center gap-x-2.5">
+                <!-- Back Button -->
+                <a
+                    href="{{ route('admin.sales.invoices.index') }}"
+                    class="transparent-button hover:bg-gray-200 dark:text-white dark:hover:bg-gray-800"
+                >
+                    @lang('admin::app.account.edit.back-btn')
+                </a>
+            </div>
+        </div>
+    </div>
+
+    <!-- Filter row -->
+    <div class="flex items-center justify-between gap-4 mt-7 max-md:flex-wrap">
+        <div class="flex flex-wrap items-center gap-x-1 gap-y-2">
+            {!! view_render_event('bagisto.admin.sales.invoice.page_action.before', ['order' => $order]) !!}
+
+            <a
+                href="{{ route('admin.sales.invoices.print', $invoice->id) }}"
+                class="inline-flex w-full max-w-max cursor-pointer items-center justify-between gap-x-2 px-1 py-1.5 text-center font-semibold text-gray-600 transition-all hover:rounded-md hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-800"
+            >
+                <span class="text-2xl icon-printer"></span>
+
+                @lang('admin::app.sales.invoices.view.print')
+            </a>
+
+            <!-- Send Duplicate Invoice Modal -->
+            <div>
+                <button
+                    type="button"
+                    class="inline-flex w-full max-w-max cursor-pointer items-center justify-between gap-x-2 px-1 py-1.5 text-center font-semibold text-gray-600 transition-all hover:rounded-md hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-800"
+                    @click="$refs.groupCreateModal.open()"
+                >
+                    <span class="text-2xl icon-mail"></span>
+
+                    @lang('admin::app.sales.invoices.view.send-duplicate-invoice')
+                </button>
+
+                <x-admin::form :action="route('admin.sales.invoices.send_duplicate_email', $invoice->id)">
+                    <!-- Create Group Modal -->
+                    <x-admin::modal ref="groupCreateModal">
+                        <!-- Modal Header -->
+                        <x-slot:header>
+                            <p class="text-lg font-bold text-gray-800 dark:text-white">
+                                @lang('admin::app.sales.invoices.view.send-duplicate-invoice')
+                            </p>
+                        </x-slot>
+
+                        <!-- Modal Content -->
+                        <x-slot:content>
+                            <x-admin::form.control-group>
+                                <x-admin::form.control-group.label class="required">
+                                    @lang('admin::app.sales.invoices.view.email')
+                                </x-admin::form.control-group.label>
+
+                                <x-admin::form.control-group.control
+                                    type="email"
+                                    id="email"
+                                    name="email"
+                                    rules="required|email"
+                                    :value="$invoice->order->customer_email"
+                                    :label="trans('admin::app.sales.invoices.view.email')"
+                                />
+
+                                <x-admin::form.control-group.error control-name="email" />
+                            </x-admin::form.control-group>
+                        </x-slot>
+
+                        <!-- Modal Footer -->
+                        <x-slot:footer>
+                            <!-- Save Button -->
+                            <x-admin::button
+                                button-type="button"
+                                class="primary-button"
+                                :title="trans('admin::app.sales.invoices.view.send')"
+                            />
+                        </x-slot>
+                    </x-admin::modal>
+                </x-admin::form>
+            </div>
+
+            {!! view_render_event('bagisto.admin.sales.invoice.page_action.after', ['order' => $order]) !!}
+
+        </div>
+    </div>
+
+    <!-- body content -->
+    <div class="mt-3.5 flex gap-2.5 max-xl:flex-wrap">
+        <!-- Left sub-component -->
+        <div class="flex flex-col flex-1 gap-2 max-xl:flex-auto">
+            <!-- Invoice Item Section -->
+            <div class="bg-white rounded box-shadow dark:bg-gray-900">
+                <p class="p-4 mb-4 text-base font-semibold text-gray-800 dark:text-white">
+                    @lang('admin::app.sales.invoices.view.invoice-items') ({{ count($invoice->items) }})
+                </p>
+
+                <div class="grid">
+                    <!-- Invoice Item Details-->
+                    @foreach($invoice->items as $item)
+                        <div class="flex justify-between gap-2.5 border-b border-slate-300 px-4 py-6 dark:border-gray-800">
+                            <div class="flex gap-2.5">
+                                <!-- Product Image -->
+                                @if ($item->product?->base_image_url)
+                                    <img
+                                        class="relative h-[60px] max-h-[60px] w-full max-w-[60px] rounded"
+                                        src="{{ $item->product->base_image_url }}"
+                                    >
+                                @else
+                                    <div class="relative h-[60px] max-h-[60px] w-full max-w-[60px] rounded border border-dashed border-gray-300 dark:border-gray-800 dark:mix-blend-exclusion dark:invert">
+                                        <img src="{{ bagisto_asset('images/product-placeholders/front.svg') }}">
+
+                                        <p class="absolute bottom-1.5 w-full text-center text-[6px] font-semibold text-gray-400">
+                                            @lang('admin::app.sales.invoices.view.product-image')
+                                        </p>
+                                    </div>
+                                @endif
+
+                                <div class="grid place-content-start gap-1.5">
+                                    <!-- Item Name -->
+                                    <p
+                                        class="text-base font-semibold text-gray-800 break-all dark:text-white"
+                                        v-pre
+                                    >
+                                        {{ $item->name }}
+                                    </p>
+
+                                    <p class="text-gray-600 dark:text-gray-300">
+                                        @lang('admin::app.sales.invoices.view.amount-per-unit', [
+                                            'amount' => core()->formatBasePrice($item->base_price),
+                                            'qty'    => $item->qty,
+                                            ])
+                                    </p>
+
+                                    <div class="flex flex-col place-items-start gap-1.5">
+                                        @if (isset($item->additional['attributes']))
+                                            <!-- Item Additional Details -->
+                                            @foreach ($item->additional['attributes'] as $attribute)
+                                                <p
+                                                    class="text-gray-600 dark:text-gray-300"
+                                                    v-pre
+                                                >
+                                                    @if (
+                                                        ! isset($attribute['attribute_type'])
+                                                        || $attribute['attribute_type'] !== 'file'
+                                                    )
+                                                        {{ $attribute['attribute_name'] }} : {{ $attribute['option_label'] }}
+                                                    @else
+                                                        {{ $attribute['attribute_name'] }} :
+
+                                                        <a
+                                                            href="{{ Storage::url($attribute['option_label']) }}"
+                                                            class="text-blue-600 hover:underline"
+                                                            download="{{ File::basename($attribute['option_label']) }}"
+                                                        >
+                                                            {{ File::basename($attribute['option_label']) }}
+                                                        </a>
+                                                    @endif
+                                                </p>
+                                            @endforeach
+                                        @endif
+
+                                        <!--SKU -->
+                                        <p class="text-gray-600 dark:text-gray-300">
+                                            @lang('admin::app.sales.invoices.view.sku', ['sku' => $item->getTypeInstance()->getOrderedItem($item)->sku])
+                                        </p>
+
+                                        <!-- Quantity -->
+                                        <p class="text-gray-600 dark:text-gray-300">
+                                            @lang('admin::app.sales.invoices.view.qty', ['qty' => $item->qty])
+                                        </p>
+                                    </div>
+                                </div>
+                            </div>
+
+                            <div class="grid gap-1 place-content-start">
+                                <!-- Item Grand Total -->
+                                <p class="flex items-center justify-end text-base font-semibold text-gray-800 gap-x-1 dark:text-white">
+                                    {{ core()->formatBasePrice($item->base_total + $item->base_tax_amount - $item->base_discount_amount) }}
+                                </p>
+
+                                <!-- Item Base Price -->
+                                <div class="flex flex-col place-items-start items-end gap-1.5">
+                                    @if (core()->getConfigData('sales.taxes.sales.display_prices') == 'including_tax')
+                                        <p class="text-gray-600 dark:text-gray-300">
+                                            @lang('admin::app.sales.invoices.view.price', ['price' => core()->formatBasePrice($item->base_price_incl_tax)])
+                                        </p>
+                                    @elseif (core()->getConfigData('sales.taxes.sales.display_prices') == 'both')
+                                        <p class="text-gray-600 dark:text-gray-300">
+                                            @lang('admin::app.sales.invoices.view.price-excl-tax', ['price' => core()->formatBasePrice($item->base_price)])
+                                        </p>
+
+                                        <p class="text-gray-600 dark:text-gray-300">
+                                            @lang('admin::app.sales.invoices.view.price-incl-tax', ['price' => core()->formatBasePrice($item->base_price_incl_tax)])
+                                        </p>
+                                    @else
+                                        <p class="text-gray-600 dark:text-gray-300">
+                                            @lang('admin::app.sales.invoices.view.price', ['price' => core()->formatBasePrice($item->base_price)])
+                                        </p>
+                                    @endif
+
+                                    <!-- Item Tax Amount -->
+                                    <p class="text-gray-600 dark:text-gray-300">
+                                        @lang('admin::app.sales.invoices.view.tax', ['tax' => core()->formatBasePrice($item->base_tax_amount)])
+                                    </p>
+
+                                    <!-- Item Discount -->
+                                    @if ($invoice->base_discount_amount > 0)
+                                        <p class="text-gray-600 dark:text-gray-300">
+                                            @lang('admin::app.sales.invoices.view.discount', ['discount' => core()->formatBasePrice($item->base_discount_amount)])
+                                        </p>
+                                    @endif
+                                    <!-- Item Sub-Total -->
+                                    @if (core()->getConfigData('sales.taxes.sales.display_subtotal') == 'including_tax')
+                                        <p class="text-gray-600 dark:text-gray-300">
+                                            @lang('admin::app.sales.invoices.view.sub-total', ['sub_total' => core()->formatBasePrice($item->base_total_incl_tax)])
+                                        </p>
+                                    @elseif (core()->getConfigData('sales.taxes.sales.display_subtotal') == 'both')
+                                        <p class="text-gray-600 dark:text-gray-300">
+                                            @lang('admin::app.sales.invoices.view.sub-total-excl-tax', ['sub_total' => core()->formatBasePrice($item->base_total)])
+                                        </p>
+
+                                        <p class="text-gray-600 dark:text-gray-300">
+                                            @lang('admin::app.sales.invoices.view.sub-total-incl-tax', ['sub_total' => core()->formatBasePrice($item->base_total_incl_tax)])
+                                        </p>
+                                    @else
+                                        <p class="text-gray-600 dark:text-gray-300">
+                                            @lang('admin::app.sales.invoices.view.sub-total', ['sub_total' => core()->formatBasePrice($item->base_total)])
+                                        </p>
+                                    @endif
+                                </div>
+                            </div>
+                        </div>
+                    @endforeach
+                </div>
+
+                <!--Sale Summary -->
+                <div class="mt-4 flex w-full justify-end gap-2.5 p-4">
+                    <div class="flex flex-col gap-y-1.5">
+                        @if (core()->getConfigData('sales.taxes.sales.display_subtotal') == 'both')
+                            <p class="font-semibold !leading-5 text-gray-600 dark:text-gray-300">
+                                @lang('admin::app.sales.invoices.view.sub-total-summary-excl-tax')
+                            </p>
+
+                            <p class="font-semibold !leading-5 text-gray-600 dark:text-gray-300">
+                                @lang('admin::app.sales.invoices.view.sub-total-summary-incl-tax')
+                            </p>
+                        @else
+                            <p class="font-semibold !leading-5 text-gray-600 dark:text-gray-300">
+                                @lang('admin::app.sales.invoices.view.sub-total-summary')
+                            </p>
+                        @endif
+
+                        @if (core()->getConfigData('sales.taxes.sales.display_shipping_amount') == 'both')
+                            <p class="!leading-5 text-gray-600 dark:text-gray-300">
+                                @lang('admin::app.sales.invoices.view.shipping-and-handling-excl-tax')
+                            </p>
+
+                            <p class="!leading-5 text-gray-600 dark:text-gray-300">
+                                @lang('admin::app.sales.invoices.view.shipping-and-handling-incl-tax')
+                            </p>
+                        @else
+                            <p class="!leading-5 text-gray-600 dark:text-gray-300">
+                                @lang('admin::app.sales.invoices.view.shipping-and-handling')
+                            </p>
+                        @endif
+
+                        <p class="!leading-5 text-gray-600 dark:text-gray-300">
+                            @lang('admin::app.sales.invoices.view.summary-tax')
+                        </p>
+
+                        @if ($invoice->base_discount_amount > 0)
+                            <p class="!leading-5 text-gray-600 dark:text-gray-300">
+                                @lang('admin::app.sales.invoices.view.summary-discount')
+                            </p>
+                        @endif
+
+                        @if ($order->giftcard_amount > 0)
+                            <p class="text-gray-600 dark:text-gray-300 !leading-5 text-sm">
+                                @lang('gift::app.giftcard.giftcard_amount')
+                            </p>
+                        @endif
+
+                        @if (!empty($order->giftcard_number))
+                            <p class="text-gray-600 dark:text-black-300 !leading-5 text-sm">
+                                @lang('gift::app.giftcard.giftcard_number')
+                            </p>
+                        @endif
+                        @if ($order->base_vip_plus_amount > 0)
+                            <p class="text-gray-600 dark:text-gray-300 !leading-5 text-sm">
+                                @lang('member::app.member.discount')
+                            </p>
+                        @endif
+                        @if ($order->base_vip_discount_amount > 0)
+                            <p class="text-gray-600 dark:text-gray-300 !leading-5 text-sm">
+                                @lang('member::app.member.vip_discount')
+                            </p>
+                        @endif
+                        <p class="text-base font-semibold !leading-5 text-gray-800 dark:text-white">
+                            @lang('admin::app.sales.invoices.view.grand-total')
+                        </p>
+
+                    </div>
+
+                    <div class="flex flex-col gap-y-1.5">
+                        <!-- Subtotal -->
+                        @if (core()->getConfigData('sales.taxes.sales.display_subtotal') == 'including_tax')
+                            <p class="font-semibold !leading-5 text-gray-600 dark:text-gray-300">
+                                {{ core()->formatBasePrice($invoice->base_sub_total_incl_tax) }}
+                            </p>
+                        @elseif (core()->getConfigData('sales.taxes.sales.display_subtotal') == 'both')
+                            <p class="font-semibold !leading-5 text-gray-600 dark:text-gray-300">
+                                {{ core()->formatBasePrice($invoice->base_sub_total) }}
+                            </p>
+
+                            <p class="font-semibold !leading-5 text-gray-600 dark:text-gray-300">
+                                {{ core()->formatBasePrice($invoice->base_sub_total_incl_tax) }}
+                            </p>
+                        @else
+                            <p class="font-semibold !leading-5 text-gray-600 dark:text-gray-300">
+                                {{ core()->formatBasePrice($invoice->base_sub_total) }}
+                            </p>
+                        @endif
+
+                        <!-- Shipping and Handling -->
+                        @if (core()->getConfigData('sales.taxes.sales.display_shipping_amount') == 'including_tax')
+                            <p class="!leading-5 text-gray-600 dark:text-gray-300">
+                                {{ core()->formatBasePrice($invoice->base_shipping_amount_incl_tax) }}
+                            </p>
+                        @elseif (core()->getConfigData('sales.taxes.sales.display_shipping_amount') == 'both')
+                            <p class="!leading-5 text-gray-600 dark:text-gray-300">
+                                {{ core()->formatBasePrice($invoice->base_shipping_amount) }}
+                            </p>
+
+                            <p class="!leading-5 text-gray-600 dark:text-gray-300">
+                                {{ core()->formatBasePrice($invoice->base_shipping_amount_incl_tax) }}
+                            </p>
+                        @else
+                            <p class="!leading-5 text-gray-600 dark:text-gray-300">
+                                {{ core()->formatBasePrice($invoice->base_shipping_amount) }}
+                            </p>
+                        @endif
+
+                        <!-- Tax -->
+                        <p class="!leading-5 text-gray-600 dark:text-gray-300">
+                            {{ core()->formatBasePrice($invoice->base_tax_amount) }}
+                        </p>
+
+                        <!-- Discount -->
+                        @if ($invoice->base_discount_amount > 0)
+                            <p class="!leading-5 text-gray-600 dark:text-gray-300">
+                                {{ core()->formatBasePrice($invoice->base_discount_amount) }}
+                            </p>
+                        @endif
+                        <!-- Giftcard Amount -->
+                        @if ($order->giftcard_amount > 0)
+                            <p class="text-gray-600 dark:text-gray-300 !leading-5">
+                                -  {{ core()->formatBasePrice($invoice->base_giftcard_amount) }}
+                            </p>
+                        @endif
+
+                        <!-- Giftcard Number -->
+                        @if (!empty($order->giftcard_number))
+                            <p class="text-gray-600 dark:text-gray-300 !leading-5">
+                                {{ ($invoice->giftcard_number) }}
+                            </p>
+                        @endif
+                        @if ($order->base_vip_plus_amount > 0)
+                            <p class="text-gray-600 dark:text-gray-300 !leading-5">
+                                +  {{ core()->formatBasePrice($invoice->base_vip_plus_amount) }}
+                            </p>
+                        @endif
+                        @if ($order->base_vip_discount_amount > 0)
+                            <p class="text-gray-600 dark:text-gray-300 !leading-5">
+                                -  {{ core()->formatBasePrice($invoice->base_vip_discount_amount) }}
+                            </p>
+                        @endif
+                        <!-- Grand Total -->
+                        <p class="text-base font-semibold !leading-5 text-gray-800 dark:text-white">
+                            {{ core()->formatBasePrice($invoice->base_grand_total) }}
+                        </p>
+                    </div>
+                </div>
+            </div>
+        </div>
+
+        <!-- Right sub-component -->
+        <div class="flex w-[360px] max-w-full flex-col gap-2 max-sm:w-full">
+            <!-- component 1 -->
+            <x-admin::accordion>
+                <x-slot:header>
+                    <p class="p-2.5 text-base font-semibold text-gray-600 dark:text-gray-300">
+                        @lang('admin::app.sales.invoices.view.customer')
+                    </p>
+                </x-slot>
+
+                <x-slot:content v-pre>
+                    <div class="flex flex-col {{ $order->billing_address ? 'pb-4' : ''}}">
+                        <p
+                            class="font-semibold text-gray-800 dark:text-white"
+                            v-text="'{{ $invoice->order->customer_full_name }}'"
+                        >
+                        </p>
+
+                        {!! view_render_event('bagisto.admin.sales.invoice.customer_name.after', ['order' => $order]) !!}
+
+                        <p class="text-gray-600 dark:text-gray-300">
+                            @lang('admin::app.sales.invoices.view.customer-email', ['email' => $invoice->order->customer_email])
+                        </p>
+
+                        {!! view_render_event('bagisto.admin.sales.invoice.customer_email.after', ['order' => $order]) !!}
+                    </div>
+
+                    @if ($order->billing_address || $order->shipping_address)
+                        <!-- Billing Address -->
+                        @if ($order->billing_address)
+                            <div class="{{ $order->shipping_address ? 'pb-4' : '' }}">
+                                <span class="block w-full border-b dark:border-gray-800"></span>
+
+                                <div class="flex items-center justify-between">
+                                    <p class="py-4 text-base font-semibold text-gray-600 dark:text-gray-300">
+                                        @lang('Billing Address')
+                                    </p>
+                                </div>
+
+                                @include ('admin::sales.address', ['address' => $order->billing_address])
+
+                                {!! view_render_event('bagisto.admin.sales.invoice.billing_address.after', ['order' => $order]) !!}
+                            </div>
+                        @endif
+
+                        <!-- Shipping Address -->
+                        @if ($order->shipping_address)
+                            <span class="block w-full border-b dark:border-gray-800"></span>
+
+                            <div class="flex items-center justify-between">
+                                <p class="py-4 text-base font-semibold text-gray-600 dark:text-gray-300">
+                                    @lang('Shipping Address')
+                                </p>
+                            </div>
+
+                            @include ('admin::sales.address', ['address' => $order->shipping_address])
+
+                            {!! view_render_event('bagisto.admin.sales.invoice.shipping_address.after', ['order' => $order]) !!}
+                        @endif
+                    @endif
+                </x-slot>
+            </x-admin::accordion>
+
+            <!-- component 2 -->
+            <x-admin::accordion>
+                <x-slot:header>
+                    <p class="p-2.5 text-base font-semibold text-gray-600 dark:text-gray-300">
+                        @lang('admin::app.sales.invoices.view.order-information')
+                    </p>
+                </x-slot>
+
+                <x-slot:content>
+                    <div class="flex justify-start w-full gap-5">
+                        <div class="flex flex-col gap-y-1.5">
+                            @foreach (['order-id', 'order-date', 'order-status', 'invoice-status', 'channel'] as $item)
+                                <p class="text-gray-600 dark:text-gray-300">
+                                    @lang('admin::app.sales.invoices.view.' . $item)
+                                </p>
+                            @endforeach
+                        </div>
+
+                        <div class="flex flex-col gap-y-1.5">
+                            <!-- Order Id -->
+                            <p class="font-semibold text-blue-600 transition-all hover:underline">
+                                <a href="{{ route('admin.sales.orders.view', $order->id) }}">#{{ $order->increment_id }}</a>
+                            </p>
+
+                            {!! view_render_event('bagisto.admin.sales.invoice.increment_id.after', ['order' => $order]) !!}
+
+                            <!-- Order Date -->
+                            <p class="text-gray-600 dark:text-gray-300">
+                                {{ core()->formatDate($order->created_at) }}
+                            </p>
+
+                            {!! view_render_event('bagisto.admin.sales.invoice.created_at.after', ['order' => $order]) !!}
+
+                            <!-- Order Status -->
+                            <p class="text-gray-600 dark:text-gray-300">
+                                {{ $order->status_label }}
+                            </p>
+
+                            {!! view_render_event('bagisto.admin.sales.invoice.status_label.after', ['order' => $order]) !!}
+
+                            <!-- Invoice Status -->
+                            <p class="text-gray-600 dark:text-gray-300">
+                                {{ $invoice->status_label }}
+                            </p>
+
+                            <!-- Order Channel -->
+                            <p
+                                class="text-gray-600 dark:text-gray-300"
+                                v-pre
+                            >
+                                {{ $order->channel_name }}
+                            </p>
+
+                            {!! view_render_event('bagisto.admin.sales.invoice.channel_name.after', ['order' => $order]) !!}
+                        </div>
+                    </div>
+                </x-slot>
+            </x-admin::accordion>
+        </div>
+    </div>
+</x-admin::layouts>

+ 955 - 0
packages/Longyi/Gift/src/Resources/views/sales/orders/view.blade.php

@@ -0,0 +1,955 @@
+<x-admin::layouts>
+    <x-slot:title>
+        @lang('admin::app.sales.orders.view.title', ['order_id' => $order->increment_id])
+    </x-slot>
+
+    <!-- Header -->
+    <div class="grid">
+        <div class="flex items-center justify-between gap-4 max-sm:flex-wrap">
+            {!! view_render_event('bagisto.admin.sales.order.title.before', ['order' => $order]) !!}
+
+            <div class="flex items-center gap-2.5">
+                <p class="text-xl font-bold leading-6 text-gray-800 dark:text-white">
+                    @lang('admin::app.sales.orders.view.title', ['order_id' => $order->increment_id])
+                </p>
+
+                <!-- Order Status -->
+                <span class="label-{{ $order->status }} text-sm mx-1.5">
+                    @lang("admin::app.sales.orders.view.$order->status")
+                </span>
+            </div>
+
+            {!! view_render_event('bagisto.admin.sales.order.title.after', ['order' => $order]) !!}
+
+            <!-- Back Button -->
+            <a
+                href="{{ route('admin.sales.orders.index') }}"
+                class="transparent-button hover:bg-gray-200 dark:text-white dark:hover:bg-gray-800"
+            >
+                @lang('admin::app.account.edit.back-btn')
+            </a>
+        </div>
+    </div>
+
+    <div class="flex-wrap items-center justify-between mt-5 gap-x-1 gap-y-2">
+        <div class="flex gap-1.5">
+            {!! view_render_event('bagisto.admin.sales.order.page_action.before', ['order' => $order]) !!}
+
+            @if (
+                $order->canReorder()
+                && bouncer()->hasPermission('sales.orders.create')
+                && core()->getConfigData('sales.order_settings.reorder.admin')
+            )
+                <a
+                    href="{{ route('admin.sales.orders.reorder', $order->id) }}"
+                    class="transparent-button px-1 py-1.5 hover:bg-gray-200 dark:text-white dark:hover:bg-gray-800"
+                >
+                    <span class="text-2xl icon-cart"></span>
+
+                    @lang('admin::app.sales.orders.view.reorder')
+                </a>
+            @endif
+
+            @if (
+                $order->canInvoice()
+                && bouncer()->hasPermission('sales.invoices.create')
+                && $order->payment->method !== 'paypal_standard'
+            )
+                @include('admin::sales.invoices.create')
+            @endif
+
+            @if (
+                $order->canShip()
+                && bouncer()->hasPermission('sales.shipments.create')
+            )
+                @include('admin::sales.shipments.create')
+            @endif
+
+            @if (
+                $order->canRefund()
+                && bouncer()->hasPermission('sales.refunds.create')
+            )
+                @include('admin::sales.refunds.create')
+            @endif
+
+            @if (
+                $order->canCancel()
+                && bouncer()->hasPermission('sales.orders.cancel')
+            )
+                <form
+                    method="POST"
+                    ref="cancelOrderForm"
+                    action="{{ route('admin.sales.orders.cancel', $order->id) }}"
+                >
+                    @csrf
+                </form>
+
+                <div
+                    class="transparent-button px-1 py-1.5 hover:bg-gray-200 dark:text-white dark:hover:bg-gray-800"
+                    @click="$emitter.emit('open-confirm-modal', {
+                        message: '@lang('admin::app.sales.orders.view.cancel-msg')',
+                        agree: () => {
+                            this.$refs['cancelOrderForm'].submit()
+                        }
+                    })"
+                >
+                    <span
+                        class="text-2xl icon-cancel"
+                        role="presentation"
+                        tabindex="0"
+                    >
+                    </span>
+
+                    <a href="javascript:void(0);">
+                        @lang('admin::app.sales.orders.view.cancel')
+                    </a>
+                </div>
+            @endif
+
+            {!! view_render_event('bagisto.admin.sales.order.page_action.after', ['order' => $order]) !!}
+        </div>
+
+        <!-- Order details -->
+        <div class="mt-3.5 flex gap-2.5 max-xl:flex-wrap">
+            <!-- Left Component -->
+            <div class="flex flex-col flex-1 gap-2 max-xl:flex-auto">
+                {!! view_render_event('bagisto.admin.sales.order.left_component.before', ['order' => $order]) !!}
+
+                <div class="bg-white rounded box-shadow dark:bg-gray-900">
+                    <div class="flex justify-between p-4">
+                        <p class="mb-4 text-base font-semibold text-gray-800 dark:text-white">
+                            @lang('Order Items') ({{ count($order->items) }})
+                        </p>
+
+                        <p class="text-base font-semibold text-gray-800 dark:text-white">
+                            @lang('admin::app.sales.orders.view.grand-total', ['grand_total' => core()->formatBasePrice($order->base_grand_total)])
+                        </p>
+                    </div>
+
+                    <!-- Order items -->
+                    <div class="grid">
+                        @foreach ($order->items as $item)
+                            {!! view_render_event('bagisto.admin.sales.order.list.before', ['order' => $order]) !!}
+
+                            <div class="flex justify-between gap-2.5 border-b border-slate-300 px-4 py-6 dark:border-gray-800">
+                                <div class="flex gap-2.5">
+                                    @if($item?->product?->base_image_url)
+                                        <img
+                                            class="relative h-[60px] max-h-[60px] w-full max-w-[60px] rounded"
+                                            src="{{ $item?->product->base_image_url }}"
+                                        >
+                                    @else
+                                        <div class="relative h-[60px] max-h-[60px] w-full max-w-[60px] rounded border border-dashed border-gray-300 dark:border-gray-800 dark:mix-blend-exclusion dark:invert">
+                                            <img src="{{ bagisto_asset('images/product-placeholders/front.svg') }}">
+
+                                            <p class="absolute bottom-1.5 w-full text-center text-[6px] font-semibold text-gray-400">
+                                                @lang('admin::app.sales.invoices.view.product-image')
+                                            </p>
+                                        </div>
+                                    @endif
+
+                                    <div class="grid place-content-start gap-1.5">
+                                        <p
+                                            class="text-base font-semibold text-gray-800 break-all dark:text-white"
+                                            v-pre
+                                        >
+                                            {{ $item->name }}
+                                        </p>
+
+                                        <div class="flex flex-col place-items-start gap-1.5">
+                                            <p class="text-gray-600 dark:text-gray-300">
+                                                @lang('admin::app.sales.orders.view.amount-per-unit', [
+                                                    'amount' => core()->formatBasePrice($item->base_price),
+                                                    'qty'    => $item->qty_ordered,
+                                                ])
+                                            </p>
+
+                                            @if (isset($item->additional['attributes']))
+                                                @foreach ($item->additional['attributes'] as $attribute)
+                                                    <p
+                                                        class="text-gray-600 dark:text-gray-300"
+                                                        v-pre
+                                                    >
+                                                        @if (
+                                                            ! isset($attribute['attribute_type'])
+                                                            || $attribute['attribute_type'] !== 'file'
+                                                        )
+                                                            {{ $attribute['attribute_name'] }} : {{ $attribute['option_label'] }}
+                                                        @else
+                                                            {{ $attribute['attribute_name'] }} :
+
+                                                            <a
+                                                                href="{{ Storage::url($attribute['option_label']) }}"
+                                                                class="text-blue-600 hover:underline"
+                                                                download="{{ File::basename($attribute['option_label']) }}"
+                                                            >
+                                                                {{ File::basename($attribute['option_label']) }}
+                                                            </a>
+                                                        @endif
+                                                    </p>
+                                                @endforeach
+                                            @endif
+
+                                            <p class="text-gray-600 dark:text-gray-300">
+                                                @lang('admin::app.sales.orders.view.sku', ['sku' => $item->sku])
+                                            </p>
+
+                                            <p class="text-gray-600 dark:text-gray-300">
+                                                {{ $item->qty_ordered ? trans('admin::app.sales.orders.view.item-ordered', ['qty_ordered' => $item->qty_ordered]) : '' }}
+
+                                                {{ $item->qty_invoiced ? trans('admin::app.sales.orders.view.item-invoice', ['qty_invoiced' => $item->qty_invoiced]) : '' }}
+
+                                                {{ $item->qty_shipped ? trans('admin::app.sales.orders.view.item-shipped', ['qty_shipped' => $item->qty_shipped]) : '' }}
+
+                                                {{ $item->qty_refunded ? trans('admin::app.sales.orders.view.item-refunded', ['qty_refunded' => $item->qty_refunded]) : '' }}
+
+                                                {{ $item->qty_canceled ? trans('admin::app.sales.orders.view.item-canceled', ['qty_canceled' => $item->qty_canceled]) : '' }}
+                                            </p>
+                                        </div>
+                                    </div>
+                                </div>
+
+                                <div class="grid gap-1 place-content-start">
+                                    <div class="">
+                                        <p class="flex items-center justify-end text-base font-semibold text-gray-800 gap-x-1 dark:text-white">
+                                            {{ core()->formatBasePrice($item->base_total + $item->base_tax_amount - $item->base_discount_amount) }}
+                                        </p>
+                                    </div>
+
+                                    <div class="flex flex-col place-items-start items-end gap-1.5">
+                                        @if (core()->getConfigData('sales.taxes.sales.display_prices') == 'including_tax')
+                                            <p class="text-gray-600 dark:text-gray-300">
+                                                @lang('admin::app.sales.orders.view.price', ['price' => core()->formatBasePrice($item->base_price_incl_tax)])
+                                            </p>
+                                        @elseif (core()->getConfigData('sales.taxes.sales.display_prices') == 'both')
+                                            <p class="text-gray-600 dark:text-gray-300">
+                                                @lang('admin::app.sales.orders.view.price-excl-tax', ['price' => core()->formatBasePrice($item->base_price)])
+                                            </p>
+
+                                            <p class="text-gray-600 dark:text-gray-300">
+                                                @lang('admin::app.sales.orders.view.price-incl-tax', ['price' => core()->formatBasePrice($item->base_price_incl_tax)])
+                                            </p>
+                                        @else
+                                            <p class="text-gray-600 dark:text-gray-300">
+                                                @lang('admin::app.sales.orders.view.price', ['price' => core()->formatBasePrice($item->base_price)])
+                                            </p>
+                                        @endif
+
+                                        <p class="text-gray-600 dark:text-gray-300">
+                                            @lang('admin::app.sales.orders.view.tax', [
+                                                'percent' => number_format($item->tax_percent, 2) . '%',
+                                                'tax'     => core()->formatBasePrice($item->base_tax_amount)
+                                            ])
+                                        </p>
+
+                                        @if ($order->base_discount_amount > 0)
+                                            <p class="text-gray-600 dark:text-gray-300">
+                                                @lang('admin::app.sales.orders.view.discount', ['discount' => core()->formatBasePrice($item->base_discount_amount)])
+                                            </p>
+                                        @endif
+
+                                        @if (core()->getConfigData('sales.taxes.sales.display_subtotal') == 'including_tax')
+                                            <p class="text-gray-600 dark:text-gray-300">
+                                                @lang('admin::app.sales.orders.view.sub-total', ['sub_total' => core()->formatBasePrice($item->base_total_incl_tax)])
+                                            </p>
+                                        @elseif (core()->getConfigData('sales.taxes.sales.display_subtotal') == 'both')
+                                            <p class="text-gray-600 dark:text-gray-300">
+                                                @lang('admin::app.sales.orders.view.sub-total-excl-tax', ['sub_total' => core()->formatBasePrice($item->base_total)])
+                                            </p>
+
+                                            <p class="text-gray-600 dark:text-gray-300">
+                                                @lang('admin::app.sales.orders.view.sub-total-incl-tax', ['sub_total' => core()->formatBasePrice($item->base_total_incl_tax)])
+                                            </p>
+                                        @else
+                                            <p class="text-gray-600 dark:text-gray-300">
+                                                @lang('admin::app.sales.orders.view.sub-total', ['sub_total' => core()->formatBasePrice($item->base_total)])
+                                            </p>
+                                        @endif
+                                    </div>
+                                </div>
+                            </div>
+
+                            {!! view_render_event('bagisto.admin.sales.order.list.after', ['order' => $order]) !!}
+
+                        @endforeach
+                    </div>
+
+                    <div class="flex justify-end flex-auto p-4 mt-4">
+                        <div class="grid gap-2 text-sm max-w-max">
+
+                            {!! view_render_event('bagisto.admin.sales.order.view.subtotal.before') !!}
+
+                            <!-- Sub Total -->
+                            @if (core()->getConfigData('sales.taxes.sales.display_subtotal') == 'including_tax')
+                                <div class="flex justify-between w-full gap-x-5">
+                                    <p class="font-semibold !leading-5 text-gray-600 dark:text-gray-300">
+                                        @lang('admin::app.sales.orders.view.summary-sub-total-incl-tax')
+                                    </p>
+
+                                    <p class="font-semibold !leading-5 text-gray-600 dark:text-gray-300">
+                                        {{ core()->formatBasePrice($order->base_sub_total_incl_tax) }}
+                                    </p>
+                                </div>
+                            @elseif (core()->getConfigData('sales.taxes.sales.display_subtotal') == 'both')
+                                <div class="flex justify-between w-full gap-x-5">
+                                    <p class="font-semibold !leading-5 text-gray-600 dark:text-gray-300">
+                                        @lang('admin::app.sales.orders.view.summary-sub-total-excl-tax')
+                                    </p>
+
+                                    <p class="font-semibold !leading-5 text-gray-600 dark:text-gray-300">
+                                        {{ core()->formatBasePrice($order->base_sub_total) }}
+                                    </p>
+                                </div>
+
+                                <div class="flex justify-between w-full gap-x-5">
+                                    <p class="font-semibold !leading-5 text-gray-600 dark:text-gray-300">
+                                        @lang('admin::app.sales.orders.view.summary-sub-total-incl-tax')
+                                    </p>
+
+                                    <p class="font-semibold !leading-5 text-gray-600 dark:text-gray-300">
+                                        {{ core()->formatBasePrice($order->base_sub_total_incl_tax) }}
+                                    </p>
+                                </div>
+                            @else
+                                <div class="flex justify-between w-full gap-x-5">
+                                    <p class="font-semibold !leading-5 text-gray-600 dark:text-gray-300">
+                                        @lang('admin::app.sales.orders.view.summary-sub-total')
+                                    </p>
+
+                                    <p class="font-semibold !leading-5 text-gray-600 dark:text-gray-300">
+                                        {{ core()->formatBasePrice($order->base_sub_total) }}
+                                    </p>
+                                </div>
+                            @endif
+
+                            {!! view_render_event('bagisto.admin.sales.order.view.subtotal.after') !!}
+
+                            {!! view_render_event('bagisto.admin.sales.order.view.shipping.before') !!}
+
+                            <!-- Shipping And Handling -->
+                            @if ($haveStockableItems = $order->haveStockableItems())
+                                @if (core()->getConfigData('sales.taxes.sales.display_subtotal') == 'including_tax')
+                                    <div class="flex justify-between w-full gap-x-5">
+                                        <p class="!leading-5 text-gray-600 dark:text-gray-300">
+                                            @lang('admin::app.sales.orders.view.shipping-and-handling-incl-tax')
+                                        </p>
+
+                                        <p class="font-semibold !leading-5 text-gray-600 dark:text-gray-300">
+                                            {{ core()->formatBasePrice($order->base_shipping_amount_incl_tax) }}
+                                        </p>
+                                    </div>
+                                @elseif (core()->getConfigData('sales.taxes.sales.display_shipping_amount') == 'both')
+                                    <div class="flex justify-between w-full gap-x-5">
+                                        <p class="!leading-5 text-gray-600 dark:text-gray-300">
+                                            @lang('admin::app.sales.orders.view.shipping-and-handling-excl-tax')
+                                        </p>
+
+                                        <p class="font-semibold !leading-5 text-gray-600 dark:text-gray-300">
+                                            {{ core()->formatBasePrice($order->base_shipping_amount) }}
+                                        </p>
+                                    </div>
+
+                                    <div class="flex justify-between w-full gap-x-5">
+                                        <p class="!leading-5 text-gray-600 dark:text-gray-300">
+                                            @lang('admin::app.sales.orders.view.shipping-and-handling-incl-tax')
+                                        </p>
+
+                                        <p class="font-semibold !leading-5 text-gray-600 dark:text-gray-300">
+                                            {{ core()->formatBasePrice($order->base_shipping_amount_incl_tax) }}
+                                        </p>
+                                    </div>
+                                @else
+                                    <div class="flex justify-between w-full gap-x-5">
+                                        <p class="!leading-5 text-gray-600 dark:text-gray-300">
+                                            @lang('admin::app.sales.orders.view.shipping-and-handling')
+                                        </p>
+
+                                        <p class="font-semibold !leading-5 text-gray-600 dark:text-gray-300">
+                                            {{ core()->formatBasePrice($order->base_shipping_amount) }}
+                                        </p>
+                                    </div>
+                                @endif
+                            @endif
+
+                            {!! view_render_event('bagisto.admin.sales.order.view.shipping.after') !!}
+
+                            {!! view_render_event('bagisto.admin.sales.order.view.tax-amount.before') !!}
+
+                            <!-- Tax Amount -->
+                            <div class="flex justify-between w-full gap-x-5">
+                                <p class="!leading-5 text-gray-600 dark:text-gray-300">
+                                    @lang('admin::app.sales.orders.view.summary-tax')
+                                </p>
+
+                                <p class="!leading-5 text-gray-600 dark:text-gray-300">
+                                    {{ core()->formatBasePrice($order->base_tax_amount) }}
+                                </p>
+                            </div>
+
+                            {!! view_render_event('bagisto.admin.sales.order.view.tax-amount.after') !!}
+
+                            {!! view_render_event('bagisto.admin.sales.order.view.discount.before') !!}
+
+                            {!! view_render_event('bagisto.admin.sales.order.view.discount.after') !!}
+
+                            {!! view_render_event('bagisto.admin.sales.order.view.giftcard.before', ['order' => $order]) !!}
+                            <!-- Discount -->
+                            <div class="flex justify-between w-full gap-x-5">
+                                <p class="!leading-5 text-gray-600 dark:text-gray-300">
+                                    @lang('admin::app.sales.orders.view.summary-discount')
+                                </p>
+
+                                <p class="!leading-5 text-gray-600 dark:text-gray-300">
+                                    {{ core()->formatBasePrice($order->base_discount_amount) }}
+                                </p>
+                            </div>
+                            @if (isset($order) && $order->giftcard_number && $order->giftcard_amount > 0)
+                                <div class="flex w-full justify-between gap-x-5">
+                                    <p>
+                                        @lang('gift::app.giftcard.applied')
+
+                                        @if ($order->giftcard_number)
+                                            ({{ $order->giftcard_number }})
+                                        @endif
+                                    </p>
+
+                                    <p>
+                                        - {{ core()->formatPrice($order->base_giftcard_amount) }}
+                                    </p>
+                                </div>
+                            @endif
+
+                            {!! view_render_event('bagisto.admin.sales.order.view.giftcard.after') !!}
+
+                            {!! view_render_event('bagisto.admin.sales.order.view.grand-total.before') !!}
+
+                            <!-- Grand Total -->
+                            <div class="flex justify-between w-full gap-x-5">
+                                <p class="text-base font-semibold !leading-5 text-gray-800 dark:text-white">
+                                    @lang('admin::app.sales.orders.view.summary-grand-total')
+                                </p>
+
+                                <p class="text-base font-semibold !leading-5 text-gray-800 dark:text-white">
+                                    {{ core()->formatBasePrice($order->base_grand_total) }}
+                                </p>
+                            </div>
+
+                            {!! view_render_event('bagisto.admin.sales.order.view.grand-total.after') !!}
+
+                            {!! view_render_event('bagisto.admin.sales.order.view.total-paid.before') !!}
+
+                            <!-- Total Paid -->
+                            <div class="flex justify-between w-full gap-x-5">
+                                <p class="!leading-5 text-gray-600 dark:text-gray-300">
+                                    @lang('admin::app.sales.orders.view.total-paid')
+                                </p>
+
+                                <p class="!leading-5 text-gray-600 dark:text-gray-300">
+                                    {{ core()->formatBasePrice($order->base_grand_total_invoiced) }}
+                                </p>
+                            </div>
+
+                            {!! view_render_event('bagisto.admin.sales.order.view.total-paid.after') !!}
+
+                            {!! view_render_event('bagisto.admin.sales.order.view.total-refunded.before') !!}
+
+                            <!-- Total Refund -->
+                            <div class="flex justify-between w-full gap-x-5">
+                                <p class="!leading-5 text-gray-600 dark:text-gray-300">
+                                    @lang('admin::app.sales.orders.view.total-refund')
+                                </p>
+
+                                <p class="!leading-5 text-gray-600 dark:text-gray-300">
+                                    {{ core()->formatBasePrice($order->base_grand_total_refunded) }}
+                                </p>
+                            </div>
+
+                            {!! view_render_event('bagisto.admin.sales.order.view.total-refunded.after') !!}
+
+                            {!! view_render_event('bagisto.admin.sales.order.view.total-due.before') !!}
+
+                            <!-- Total Due -->
+                            <div class="flex justify-between w-full font-semibold gap-x-5">
+                                <p class="!leading-5 text-gray-600 dark:text-gray-300">
+                                    @lang('admin::app.sales.orders.view.total-due')
+                                </p>
+
+                                @if($order->status !== 'canceled')
+                                    <p class="!leading-5 text-gray-600 dark:text-gray-300">
+                                        {{ core()->formatBasePrice($order->base_total_due) }}
+                                    </p>
+                                @else
+                                    <p class="!leading-5 text-gray-600 dark:text-gray-300">
+                                        {{ core()->formatBasePrice(0.00) }}
+                                    </p>
+                                @endif
+                            </div>
+
+                            {!! view_render_event('bagisto.admin.sales.order.view.total-due.after') !!}
+
+                        </div>
+                    </div>
+                </div>
+
+                <!-- Customer's comment form -->
+                <div class="bg-white rounded box-shadow dark:bg-gray-900">
+                    <p class="p-4 pb-0 text-base font-semibold text-gray-800 dark:text-white">
+                        @lang('admin::app.sales.orders.view.comments')
+                    </p>
+
+                    <x-admin::form action="{{ route('admin.sales.orders.comment', $order->id) }}">
+                        <div class="p-4">
+                            <div class="mb-2.5">
+                                <x-admin::form.control-group>
+                                    <x-admin::form.control-group.control
+                                        type="textarea"
+                                        id="comment"
+                                        name="comment"
+                                        rules="required"
+                                        :label="trans('admin::app.sales.orders.view.comments')"
+                                        :placeholder="trans('admin::app.sales.orders.view.write-your-comment')"
+                                        rows="3"
+                                    />
+
+                                    <x-admin::form.control-group.error control-name="comment" />
+                                </x-admin::form.control-group>
+                            </div>
+
+                            <div class="flex items-center justify-between">
+                                <label
+                                    class="flex w-max cursor-pointer select-none items-center gap-1 p-1.5"
+                                    for="customer_notified"
+                                >
+                                    <input
+                                        type="checkbox"
+                                        name="customer_notified"
+                                        id="customer_notified"
+                                        value="1"
+                                        class="hidden peer"
+                                    >
+
+                                    <span
+                                        class="text-2xl rounded-md cursor-pointer icon-uncheckbox peer-checked:icon-checked peer-checked:text-blue-600"
+                                        role="button"
+                                        tabindex="0"
+                                    >
+                                    </span>
+
+                                    <p class="flex items-center font-semibold text-gray-600 cursor-pointer gap-x-1 hover:text-gray-800 dark:text-gray-300 dark:hover:text-gray-100">
+                                        @lang('admin::app.sales.orders.view.notify-customer')
+                                    </p>
+                                </label>
+
+                                <button
+                                    type="submit"
+                                    class="secondary-button"
+                                    aria-label="{{ trans('admin::app.sales.orders.view.submit-comment') }}"
+                                >
+                                    @lang('admin::app.sales.orders.view.submit-comment')
+                                </button>
+                            </div>
+                        </div>
+                    </x-admin::form>
+
+                    <span class="block w-full border-b dark:border-gray-800"></span>
+
+                    <!-- Comment List -->
+                    @foreach ($order->comments()->orderBy('id', 'desc')->get() as $comment)
+                        <div class="grid gap-1.5 p-4">
+                            <p
+                                class="text-base leading-6 text-gray-800 break-all dark:text-white"
+                                v-pre
+                            >
+                                {{ $comment->comment }}
+                            </p>
+
+                            <!-- Notes List Title and Time -->
+                            <p class="flex items-center gap-2 text-gray-600 dark:text-gray-300">
+                                @if ($comment->customer_notified)
+                                    <span class="text-2xl text-blue-600 bg-blue-100 rounded-full icon-done h-fit"></span>
+
+                                    @lang('admin::app.sales.orders.view.customer-notified', ['date' => core()->formatDate($comment->created_at, 'Y-m-d H:i:s a')])
+                                @else
+                                    <span class="text-2xl text-red-600 bg-red-100 rounded-full icon-cancel-1 h-fit"></span>
+
+                                    @lang('admin::app.sales.orders.view.customer-not-notified', ['date' => core()->formatDate($comment->created_at, 'Y-m-d H:i:s a')])
+                                @endif
+                            </p>
+                        </div>
+
+                        <span class="block w-full border-b dark:border-gray-800"></span>
+                    @endforeach
+                </div>
+
+                {!! view_render_event('bagisto.admin.sales.order.left_component.after', ['order' => $order]) !!}
+            </div>
+
+            <!-- Right Component -->
+            <div class="flex w-[360px] max-w-full flex-col gap-2 max-sm:w-full">
+                {!! view_render_event('bagisto.admin.sales.order.right_component.before', ['order' => $order]) !!}
+
+                <!-- Customer and address information -->
+                <x-admin::accordion>
+                    <x-slot:header>
+                        <p class="p-2.5 text-base font-semibold text-gray-600 dark:text-gray-300">
+                            @lang('admin::app.sales.orders.view.customer')
+                        </p>
+                    </x-slot>
+
+                    <x-slot:content v-pre>
+                        <div class="{{ $order->billing_address ? 'pb-4' : '' }}">
+                            <div class="flex flex-col gap-1.5">
+                                <p
+                                    class="font-semibold text-gray-800 dark:text-white"
+                                    v-pre
+                                >
+                                    {{ $order->customer_full_name }}
+                                </p>
+
+                                {!! view_render_event('bagisto.admin.sales.order.customer_full_name.after', ['order' => $order]) !!}
+
+                                <p
+                                    class="text-gray-600 dark:text-gray-300"
+                                    v-pre
+                                >
+                                    {{ $order->customer_email }}
+                                </p>
+
+                                {!! view_render_event('bagisto.admin.sales.order.customer_email.after', ['order' => $order]) !!}
+
+                                <p
+                                    class="text-gray-600 dark:text-gray-300"
+                                    v-pre
+                                >
+                                    @lang('admin::app.sales.orders.view.customer-group') : {{ $order->is_guest ? core()->getGuestCustomerGroup()?->name : ($order->customer->group->name ?? '') }}
+                                </p>
+
+                                {!! view_render_event('bagisto.admin.sales.order.customer_group.after', ['order' => $order]) !!}
+                            </div>
+                        </div>
+
+                        <!-- Billing Address -->
+                        @if ($order->billing_address)
+                            <span class="block w-full border-b dark:border-gray-800"></span>
+
+                            <div class="{{ $order->shipping_address ? 'pb-4' : '' }}">
+
+                                <div class="flex items-center justify-between">
+                                    <p class="py-4 text-base font-semibold text-gray-600 dark:text-gray-300">
+                                        @lang('admin::app.sales.orders.view.billing-address')
+                                    </p>
+                                </div>
+
+                                @include ('admin::sales.address', ['address' => $order->billing_address])
+
+                                {!! view_render_event('bagisto.admin.sales.order.billing_address.after', ['order' => $order]) !!}
+                            </div>
+                        @endif
+
+                        <!-- Shipping Address -->
+                        @if ($order->shipping_address)
+                            <span class="block w-full border-b dark:border-gray-800"></span>
+
+                            <div class="flex items-center justify-between">
+                                <p class="py-4 text-base font-semibold text-gray-600 dark:text-gray-300">
+                                    @lang('admin::app.sales.orders.view.shipping-address')
+                                </p>
+                            </div>
+
+                            @include ('admin::sales.address', ['address' => $order->shipping_address])
+
+                            {!! view_render_event('bagisto.admin.sales.order.shipping_address.after', ['order' => $order]) !!}
+                        @endif
+                    </x-slot>
+                </x-admin::accordion>
+
+                <!-- Order Information -->
+                <x-admin::accordion>
+                    <x-slot:header>
+                        <p class="p-2.5 text-base font-semibold text-gray-600 dark:text-gray-300">
+                            @lang('admin::app.sales.orders.view.order-information')
+                        </p>
+                    </x-slot>
+
+                    <x-slot:content>
+                        <div class="flex justify-start w-full gap-5">
+                            <div class="flex flex-col gap-y-1.5">
+                                <p class="text-gray-600 dark:text-gray-300">
+                                    @lang('admin::app.sales.orders.view.order-date')
+                                </p>
+
+                                <p class="text-gray-600 dark:text-gray-300">
+                                    @lang('admin::app.sales.orders.view.order-status')
+                                </p>
+
+                                <p class="text-gray-600 dark:text-gray-300">
+                                    @lang('admin::app.sales.orders.view.channel')
+                                </p>
+                            </div>
+
+                            <div class="flex flex-col gap-y-1.5">
+                                {!! view_render_event('bagisto.admin.sales.order.created_at.before', ['order' => $order]) !!}
+
+                                <!-- Order Date -->
+                                <p class="text-gray-600 dark:text-gray-300">
+                                    {{core()->formatDate($order->created_at) }}
+                                </p>
+
+                                {!! view_render_event('bagisto.admin.sales.order.created_at.after', ['order' => $order]) !!}
+
+                                <!-- Order Status -->
+                                <p class="text-gray-600 dark:text-gray-300">
+                                    {{$order->status_label}}
+                                </p>
+
+                                {!! view_render_event('bagisto.admin.sales.order.status_label.after', ['order' => $order]) !!}
+
+                                <!-- Order Channel -->
+                                <p class="text-gray-600 dark:text-gray-300">
+                                    {{$order->channel_name}}
+                                </p>
+
+                                {!! view_render_event('bagisto.admin.sales.order.channel_name.after', ['order' => $order]) !!}
+                            </div>
+                        </div>
+                    </x-slot>
+                </x-admin::accordion>
+
+                <!-- Payment and Shipping Information-->
+                <x-admin::accordion>
+                    <x-slot:header>
+                        <p class="p-2.5 text-base font-semibold text-gray-600 dark:text-gray-300">
+                            @lang('admin::app.sales.orders.view.payment-and-shipping')
+                        </p>
+                    </x-slot>
+
+                    <x-slot:content>
+                        <div>
+                            <!-- Payment method -->
+                            <p class="font-semibold text-gray-800 dark:text-white">
+                                {{ core()->getConfigData('sales.payment_methods.' . $order->payment->method . '.title') }}
+                            </p>
+
+                            <p class="text-gray-600 dark:text-gray-300">
+                                @lang('admin::app.sales.orders.view.payment-method')
+                            </p>
+
+                            <!-- Currency -->
+                            <p
+                                class="pt-4 font-semibold text-gray-800 dark:text-white"
+                                v-pre
+                            >
+                                {{ $order->order_currency_code }}
+                            </p>
+
+                            <p class="text-gray-600 dark:text-gray-300">
+                                @lang('admin::app.sales.orders.view.currency')
+                            </p>
+
+                            @php $additionalDetails = \Webkul\Payment\Payment::getAdditionalDetails($order->payment->method); @endphp
+
+                                <!-- Additional details -->
+                            @if (! empty($additionalDetails))
+                                <p
+                                    class="pt-4 font-semibold text-gray-800 dark:text-white"
+                                    v-pre
+                                >
+                                    {{ $additionalDetails['title'] }}
+                                </p>
+
+                                <p
+                                    class="text-gray-600 dark:text-gray-300"
+                                    v-pre
+                                >
+                                    {{ $additionalDetails['value'] }}
+                                </p>
+                            @endif
+
+                            {!! view_render_event('bagisto.admin.sales.order.payment-method.after', ['order' => $order]) !!}
+                        </div>
+
+                        <!-- Shipping Method and Price Details -->
+                        @if ($order->shipping_address)
+                            <span class="block w-full mt-4 border-b dark:border-gray-800"></span>
+
+                            <div class="pt-4">
+                                <p
+                                    class="font-semibold text-gray-800 dark:text-white"
+                                    v-pre
+                                >
+                                    {{ $order->shipping_title }}
+                                </p>
+
+                                <p class="text-gray-600 dark:text-gray-300">
+                                    @lang('admin::app.sales.orders.view.shipping-method')
+                                </p>
+
+                                <p class="pt-4 font-semibold text-gray-800 dark:text-white">
+                                    {{ core()->formatBasePrice($order->base_shipping_amount) }}
+                                </p>
+
+                                <p class="text-gray-600 dark:text-gray-300">
+                                    @lang('admin::app.sales.orders.view.shipping-price')
+                                </p>
+                            </div>
+
+                            {!! view_render_event('bagisto.admin.sales.order.shipping-method.after', ['order' => $order]) !!}
+                        @endif
+                    </x-slot>
+                </x-admin::accordion>
+
+                <!-- Invoice Information-->
+                <x-admin::accordion>
+                    <x-slot:header>
+                        <p class="p-2.5 text-base font-semibold text-gray-600 dark:text-gray-300">
+                            @lang('admin::app.sales.orders.view.invoices') ({{ count($order->invoices) }})
+                        </p>
+                    </x-slot>
+
+                    <x-slot:content>
+                        @forelse ($order->invoices as $index => $invoice)
+                            <div class="grid gap-y-2.5">
+                                <div>
+                                    <p class="font-semibold text-gray-800 dark:text-white">
+                                        @lang('admin::app.sales.orders.view.invoice-id', ['invoice' => $invoice->increment_id ?? $invoice->id])
+                                    </p>
+
+                                    <p class="text-gray-600 dark:text-gray-300">
+                                        {{ core()->formatDate($invoice->created_at, 'd M, Y H:i:s a') }}
+                                    </p>
+                                </div>
+
+                                <div class="flex gap-2.5">
+                                    <a
+                                        href="{{ route('admin.sales.invoices.view', $invoice->id) }}"
+                                        class="text-sm text-blue-600 transition-all hover:underline"
+                                    >
+                                        @lang('admin::app.sales.orders.view.view')
+                                    </a>
+
+                                    <a
+                                        href="{{ route('admin.sales.invoices.print', $invoice->id) }}"
+                                        class="text-sm text-blue-600 transition-all hover:underline"
+                                    >
+                                        @lang('admin::app.sales.orders.view.download-pdf')
+                                    </a>
+                                </div>
+                            </div>
+
+                            @if ($index < count($order->invoices) - 1)
+                                <span class="block w-full mt-4 mb-4 border-b dark:border-gray-800"></span>
+                            @endif
+                        @empty
+                            <p class="text-gray-600 dark:text-gray-300">
+                                @lang('admin::app.sales.orders.view.no-invoice-found')
+                            </p>
+                        @endforelse
+                    </x-slot>
+                </x-admin::accordion>
+
+                <!-- Shipment Information-->
+                <x-admin::accordion>
+                    <x-slot:header>
+                        <p class="p-2.5 text-base font-semibold text-gray-600 dark:text-gray-300">
+                            @lang('admin::app.sales.orders.view.shipments') ({{ count($order->shipments) }})
+                        </p>
+                    </x-slot>
+
+                    <x-slot:content>
+                        @forelse ($order->shipments as $shipment)
+                            <div class="grid gap-y-2.5">
+                                <div>
+                                    <!-- Shipment Id -->
+                                    <p class="font-semibold text-gray-800 dark:text-white">
+                                        @lang('admin::app.sales.orders.view.shipment', ['shipment' => $shipment->id])
+                                    </p>
+
+                                    <!-- Shipment Created -->
+                                    <p class="text-gray-600 dark:text-gray-300">
+                                        {{ core()->formatDate($shipment->created_at, 'd M, Y H:i:s a') }}
+                                    </p>
+                                </div>
+
+                                <div class="flex gap-2.5">
+                                    <a
+                                        href="{{ route('admin.sales.shipments.view', $shipment->id) }}"
+                                        class="text-sm text-blue-600 transition-all hover:underline"
+                                    >
+                                        @lang('admin::app.sales.orders.view.view')
+                                    </a>
+                                </div>
+                            </div>
+                        @empty
+                            <p class="text-gray-600 dark:text-gray-300">
+                                @lang('admin::app.sales.orders.view.no-shipment-found')
+                            </p>
+                        @endforelse
+                    </x-slot>
+                </x-admin::accordion>
+
+                <!-- Refund Information -->
+                <x-admin::accordion>
+                    <x-slot:header>
+                        <p class="p-2.5 text-base font-semibold text-gray-600 dark:text-gray-300">
+                            @lang('admin::app.sales.orders.view.refund')
+                        </p>
+                    </x-slot>
+
+                    <x-slot:content>
+                        @forelse ($order->refunds as $refund)
+                            <div class="grid gap-y-2.5">
+                                <div>
+                                    <p class="font-semibold text-gray-800 dark:text-white">
+                                        @lang('admin::app.sales.orders.view.refund-id', ['refund' => $refund->id])
+                                    </p>
+
+                                    <p class="text-gray-600 dark:text-gray-300">
+                                        {{ core()->formatDate($refund->created_at, 'd M, Y H:i:s a') }}
+                                    </p>
+
+                                    <p class="mt-4 font-semibold text-gray-800 dark:text-white">
+                                        @lang('admin::app.sales.orders.view.name')
+                                    </p>
+
+                                    <p
+                                        class="text-gray-600 dark:text-gray-300"
+                                        v-pre
+                                    >
+                                        {{ $refund->order->customer_full_name }}
+                                    </p>
+
+                                    <p class="mt-4 font-semibold text-gray-800 dark:text-white">
+                                        @lang('admin::app.sales.orders.view.status')
+                                    </p>
+
+                                    <p class="text-gray-600 dark:text-gray-300">
+                                        @lang('admin::app.sales.orders.view.refunded')
+
+                                        <span class="font-semibold text-gray-800 dark:text-white">
+                                            {{ core()->formatBasePrice($refund->base_grand_total) }}
+                                        </span>
+                                    </p>
+                                </div>
+
+                                <div class="flex gap-2.5">
+                                    <a
+                                        href="{{ route('admin.sales.refunds.view', $refund->id) }}"
+                                        class="text-sm text-blue-600 transition-all hover:underline"
+                                    >
+                                        @lang('admin::app.sales.orders.view.view')
+                                    </a>
+                                </div>
+                            </div>
+                        @empty
+                            <p class="text-gray-600 dark:text-gray-300">
+                                @lang('admin::app.sales.orders.view.no-refund-found')
+                            </p>
+                        @endforelse
+                    </x-slot>
+                </x-admin::accordion>
+
+                {!! view_render_event('bagisto.admin.sales.order.right_component.after', ['order' => $order]) !!}
+            </div>
+        </div>
+    </div>
+</x-admin::layouts>

+ 11 - 0
packages/Longyi/Gift/src/Resources/views/shop/index.blade.php

@@ -0,0 +1,11 @@
+<x-shop::layouts>
+
+    <!-- Title of the page -->
+    <x-slot:title>
+        Package Gift
+    </x-slot>
+
+    <div class="main">
+        Package Gift
+    </div>
+</x-shop::layouts>

+ 18 - 0
packages/Longyi/Gift/src/Routes/admin-routes.php

@@ -0,0 +1,18 @@
+<?php
+
+use Illuminate\Support\Facades\Route;
+use Longyi\Gift\Http\Controllers\Admin\GiftController;
+
+Route::group(['middleware' => ['web', 'admin'], 'prefix' => 'admin/gift'], function () {
+    Route::controller(GiftController::class)->group(function () {
+        Route::get('', 'index')->name('admin.gift.index');
+        // Create routes
+        Route::get('/create', 'create')->name('admin.gift.create');
+        Route::post('/store', 'store')->name('admin.gift.store');
+        // Delete routes
+        Route::delete('/{id}', 'destroy')->name('admin.gift.destroy');
+        Route::post('/mass-delete', 'massDelete')->name('admin.gift.mass_delete');
+        //validate
+        Route::post('/validate-email', 'validateEmail')->name('admin.gift.validate_email');
+    });
+});

+ 14 - 0
packages/Longyi/Gift/src/Routes/shop-routes.php

@@ -0,0 +1,14 @@
+<?php
+
+use Illuminate\Support\Facades\Route;
+use Longyi\Gift\Http\Controllers\Shop\GiftController;
+
+Route::group(['middleware' => ['web', 'theme', 'locale', 'currency'], 'prefix' => 'gift'], function () {
+    Route::get('', [GiftController::class, 'index'])->name('shop.gift.index');
+});
+Route::group(['middleware' => ['web', 'theme', 'locale', 'currency', 'customer'], 'prefix' => 'gift'], function () {
+    Route::get('/my-gift-cards', [GiftController::class, 'getCustomerGiftCards'])->name('shop.api.checkout.cart.giftcard.list');
+    Route::post('/activate', [GiftController::class, 'activateGiftCard'])->name('shop.api.checkout.cart.giftcard.activate');
+    Route::delete('/remove', [GiftController::class, 'destroyGiftCard'])->name('shop.api.checkout.cart.giftcard.remove');
+    Route::post('/auto-apply-expiring', [GiftController::class, 'autoApplyExpiringGiftCard'])->name('shop.api.checkout.cart.giftcard.auto-apply');
+});

+ 4 - 0
packages/Longyi/Gift/tailwind.config.js

@@ -0,0 +1,4 @@
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+    content: ["./src/Resources/**/*.blade.php", "./src/Resources/**/*.js"],
+};

+ 46 - 0
packages/Longyi/Gift/vite.config.js

@@ -0,0 +1,46 @@
+import { defineConfig, loadEnv } from "vite";
+import vue from "@vitejs/plugin-vue";
+import laravel from "laravel-vite-plugin";
+import path from "path";
+
+export default defineConfig(({ mode }) => {
+    const envDir = "../../../";
+
+    Object.assign(process.env, loadEnv(mode, envDir));
+
+    return {
+        build: {
+            emptyOutDir: true,
+        },
+
+        envDir,
+
+        server: {
+            host: process.env.VITE_HOST || "localhost",
+            port: process.env.VITE_PORT || 5173,
+        },
+
+        plugins: [
+            vue(),
+
+            laravel({
+                hotFile: "../../../public/gift-default-vite.hot",
+                publicDirectory: "../../../public",
+                buildDirectory: "themes/gift/default/build",
+                input: [
+                    "src/Resources/assets/css/app.css",
+                    "src/Resources/assets/js/app.js",
+                ],
+                refresh: true,
+            }),
+        ],
+
+        experimental: {
+            renderBuiltUrl(filename, { hostId, hostType, type }) {
+                if (hostType === "css") {
+                    return path.basename(filename);
+                }
+            },
+        },
+    };
+});

+ 23 - 0
packages/Longyi/Member/package.json

@@ -0,0 +1,23 @@
+{
+  "private": true,
+  "scripts": {
+    "dev": "vite",
+    "build": "vite build"
+  },
+  "devDependencies": {
+    "autoprefixer": "^10.4.14",
+    "axios": "^1.4.0",
+    "laravel-vite-plugin": "^0.7.2",
+    "postcss": "^8.4.23",
+    "tailwindcss": "^3.3.2",
+    "vite": "^4.0.0",
+    "vue": "^3.2.47"
+  },
+  "dependencies": {
+    "@vee-validate/i18n": "^4.9.1",
+    "@vee-validate/rules": "^4.9.1",
+    "@vitejs/plugin-vue": "^4.2.3",
+    "mitt": "^3.0.1",
+    "vee-validate": "^4.9.1"
+  }
+}

+ 6 - 0
packages/Longyi/Member/postcss.config.js

@@ -0,0 +1,6 @@
+module.exports = {
+  plugins: {
+    tailwindcss: {},
+    autoprefixer: {},
+  },
+}

+ 10 - 0
packages/Longyi/Member/src/Config/acl.php

@@ -0,0 +1,10 @@
+<?php
+
+return [
+    [
+        'key'   => 'member',
+        'name'  => 'Member',
+        'route' => 'admin.member.index',
+        'sort'  => 101
+    ]
+];

+ 11 - 0
packages/Longyi/Member/src/Config/admin-menu.php

@@ -0,0 +1,11 @@
+<?php
+
+return [
+    [
+        'key'   => 'member',
+        'name'  => 'Member',
+        'route' => 'admin.member.index',
+        'sort'  => 101,
+        'icon'  => 'icon-sales',
+    ]
+];

+ 7 - 0
packages/Longyi/Member/src/Contracts/MemberLog.php

@@ -0,0 +1,7 @@
+<?php
+
+namespace Longyi\Member\Contracts;
+
+interface MemberLog
+{
+}

+ 107 - 0
packages/Longyi/Member/src/DataGrids/Member/MemberDataGrid.php

@@ -0,0 +1,107 @@
+<?php
+
+namespace Longyi\Member\DataGrids\Member;
+
+use Illuminate\Support\Facades\DB;
+use Webkul\DataGrid\DataGrid;
+
+class MemberDataGrid extends DataGrid
+{
+    /**
+     * Prepare query builder.
+     *
+     * @return \Illuminate\Database\Query\Builder
+     */
+    public function prepareQueryBuilder()
+    {
+        $queryBuilder = DB::table('member_log')
+            ->leftJoin('customers', 'member_log.customer_id', '=', 'customers.id')
+            ->select(
+                'member_log.id',
+                'member_log.customer_id',
+                'member_log.order_id',
+                'member_log.amount',
+                'customers.email as customer_email',
+                'customers.first_name',
+                'customers.last_name',
+                'member_log.expirationdate',
+                'member_log.created_at',
+                'member_log.updated_at'
+            );
+
+        $this->addFilter('id', 'member_log.id');
+        $this->addFilter('order_id', 'member_log.order_id');
+        $this->addFilter('amount', 'member_log.amount');
+        $this->addFilter('expirationdate', 'member_log.expirationdate');
+        $this->addFilter('created_at', 'member_log.created_at');
+        $this->addFilter('customer_email', 'customers.email');
+
+        return $queryBuilder;
+    }
+
+    /**
+     * Add columns.
+     *
+     * @return void
+     */
+    public function prepareColumns()
+    {
+        $this->addColumn([
+            'index'      => 'id',
+            'label'      => 'id',
+            'type'       => 'integer',
+            'filterable' => true,
+            'sortable'   => true,
+        ]);
+        $this->addColumn([
+            'index'      => 'customer_email',
+            'label'      => '用户邮箱',
+            'type'       => 'string',
+            'filterable' => true,
+            'sortable'   => true,
+            'searchable' => true,
+            'closure'    => function ($row) {
+                if ($row->customer_email) {
+                    $customerName = '';
+                    if ($row->first_name || $row->last_name) {
+                        $customerName = ' (' . trim($row->first_name . ' ' . $row->last_name) . ')';
+                    }
+                    return $row->customer_email . $customerName;
+                }
+            },
+        ]);
+        $this->addColumn([
+            'index'      => 'order_id',
+            'label'      => '#order Id',
+            'type'       => 'integer',
+            'filterable' => false,
+            'sortable'   => true
+        ]);
+        $this->addColumn([
+            'index'      => 'amount',
+            'label'      => 'vip amount',
+            'type'       => 'aggregate',
+            'filterable' => false,
+            'sortable'   => true,
+            'closure'    => function ($row) {
+                return core()->formatPrice($row->amount);
+            },
+        ]);
+        $this->addColumn([
+            'index'      => 'expirationdate',
+            'label'      => 'expirationdate',
+            'type'       => 'date',
+            'filterable' => true,
+            'sortable'   => true,
+        ]);
+
+
+        $this->addColumn([
+            'index'      => 'created_at',
+            'label'      => 'created at',
+            'type'       => 'datetime',
+            'filterable' => true,
+            'sortable'   => true,
+        ]);
+    }
+}

+ 28 - 0
packages/Longyi/Member/src/Database/Migrations/2026_04_15_233520_add_vip_expire_fields_to_customer_table.php

@@ -0,0 +1,28 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::table('customers', function (Blueprint $table) {
+            $table->dateTime('vip_expire_date')->nullable()->after('remember_token');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::table('customers', function (Blueprint $table) {
+            $table->dropColumn('vip_expire_date');
+        });
+    }
+};

+ 31 - 0
packages/Longyi/Member/src/Database/Migrations/2026_04_15_233955_add_member_fields_to_cart_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
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::table('cart', function (Blueprint $table) {
+            $table->decimal('vip_plus_amount', 10, 2)->nullable()->after('base_giftcard_amount');
+            $table->decimal('base_vip_plus_amount', 10, 2)->nullable()->after('vip_plus_amount');
+            $table->decimal('vip_discount_amount', 10, 2)->nullable()->after('base_vip_plus_amount');
+            $table->decimal('base_vip_discount_amount', 10, 2)->nullable()->after('vip_discount_amount');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::table('cart', function (Blueprint $table) {
+            $table->dropColumn(['vip_plus_amount','base_vip_plus_amount', 'vip_discount_amount', 'vip_discount_amount']);
+        });
+    }
+};

+ 31 - 0
packages/Longyi/Member/src/Database/Migrations/2026_04_15_234016_add_member_fields_to_orders_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
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::table('orders', function (Blueprint $table) {
+            $table->decimal('vip_plus_amount', 10, 2)->nullable()->after('base_giftcard_amount');
+            $table->decimal('base_vip_plus_amount', 10, 2)->nullable()->after('vip_plus_amount');
+            $table->decimal('vip_discount_amount', 10, 2)->nullable()->after('base_vip_plus_amount');
+            $table->decimal('base_vip_discount_amount', 10, 2)->nullable()->after('vip_discount_amount');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::table('orders', function (Blueprint $table) {
+            $table->dropColumn(['vip_plus_amount','base_vip_plus_amount', 'vip_discount_amount', 'vip_discount_amount']);
+        });
+    }
+};

+ 31 - 0
packages/Longyi/Member/src/Database/Migrations/2026_04_15_234035_add_member_fields_to_invoices_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
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::table('invoices', function (Blueprint $table) {
+            $table->decimal('vip_plus_amount', 10, 2)->nullable()->after('base_giftcard_amount');
+            $table->decimal('base_vip_plus_amount', 10, 2)->nullable()->after('vip_plus_amount');
+            $table->decimal('vip_discount_amount', 10, 2)->nullable()->after('base_vip_plus_amount');
+            $table->decimal('base_vip_discount_amount', 10, 2)->nullable()->after('vip_discount_amount');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::table('invoices', function (Blueprint $table) {
+            $table->dropColumn(['vip_plus_amount','base_vip_plus_amount', 'vip_discount_amount', 'vip_discount_amount']);
+        });
+    }
+};

+ 32 - 0
packages/Longyi/Member/src/Database/Migrations/2026_04_20_180518_create_member_log_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
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::create('member_log', function (Blueprint $table) {
+            $table->id();
+            $table->integer('customer_id')->index()->default(0)->nullable();
+            $table->integer('order_id')->index()->default(0)->nullable();
+            $table->tinyInteger('type')->default(1)->nullable();
+            $table->decimal('amount', 10, 2)->nullable()->comment('amount');
+            $table->dateTime('expirationdate')->nullable()->comment('过期时间');
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('member_log');
+    }
+};

+ 23 - 0
packages/Longyi/Member/src/Http/Controllers/Admin/MemberController.php

@@ -0,0 +1,23 @@
+<?php
+
+namespace Longyi\Member\Http\Controllers\Admin;
+
+use Illuminate\Http\JsonResponse;
+use Illuminate\View\View;
+use Longyi\Member\DataGrids\Member\MemberDataGrid;
+use Webkul\Admin\Http\Controllers\Controller;
+
+class MemberController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     */
+    public function index(): View|JsonResponse
+    {
+        if (request()->ajax()) {
+            return datagrid(MemberDataGrid::class)->process();
+        }
+
+        return view('member::admin.index');
+    }
+}

+ 17 - 0
packages/Longyi/Member/src/Http/Controllers/Shop/MemberController.php

@@ -0,0 +1,17 @@
+<?php
+
+namespace Longyi\Member\Http\Controllers\Shop;
+
+use Illuminate\View\View;
+use Webkul\Shop\Http\Controllers\Controller;
+
+class MemberController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     */
+    public function index(): View
+    {
+        return view('member::shop.index');
+    }
+}

+ 99 - 0
packages/Longyi/Member/src/Http/Controllers/Shop/MemberDiscountController.php

@@ -0,0 +1,99 @@
+<?php
+
+namespace Longyi\Member\Http\Controllers\Shop;
+
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Response;
+use Webkul\Checkout\Facades\Cart;
+use Webkul\Shop\Http\Controllers\Controller;
+
+class MemberDiscountController extends Controller
+{
+    protected const MEMBER_DISCOUNT_AMOUNT = 19.9;
+
+    /**
+     * Apply member discount to cart.
+     */
+    public function apply(): JsonResponse
+    {
+        try {
+            if (!auth()->guard('customer')->check()) {
+                return response()->json([
+                    'success' => false,
+                    'message' => 'Please log in to your member account first',
+                ], Response::HTTP_UNAUTHORIZED);
+            }
+
+            $cart = Cart::getCart();
+
+            if (!$cart) {
+                return response()->json([
+                    'success' => false,
+                    'message' => 'Shopping cart is empty',
+                ], Response::HTTP_NOT_FOUND);
+            }
+
+            if ($cart->vip_plus_amount && $cart->vip_plus_amount > 0) {
+                return response()->json([
+                    'success' => false,
+                    'message' => 'Member discount has been applied',
+                ], Response::HTTP_OK);
+            }
+
+            $discountAmountInCurrentCurrency = core()->convertPrice(self::MEMBER_DISCOUNT_AMOUNT);
+
+            $cart->vip_plus_amount = $discountAmountInCurrentCurrency;
+            $cart->base_vip_plus_amount = core()->convertToBasePrice($discountAmountInCurrentCurrency);
+            $cart->save();
+            Cart::collectTotals();
+
+            return response()->json([
+                'success' => true,
+                'message' => 'Member discount has been applied',
+                'discount_amount' => $cart->vip_plus_amount,
+            ]);
+        } catch (\Exception $e) {
+            return response()->json([
+                'success' => false,
+                'message' => 'Failed to apply member discount',
+                'error' => $e->getMessage(),
+            ], Response::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * Remove member discount from cart.
+     */
+    public function remove(): JsonResponse
+    {
+        try {
+            $cart = Cart::getCart();
+
+            if (!$cart || !$cart->vip_plus_amount || $cart->vip_plus_amount <= 0) {
+                return response()->json([
+                    'success' => false,
+                    'message' => 'Member discount not applied',
+                ], Response::HTTP_OK);
+            }
+
+            $discountAmount = $cart->vip_plus_amount;
+            $baseDiscountAmount = $cart->base_vip_plus_amount;
+
+            $cart->vip_plus_amount = null;
+            $cart->base_vip_plus_amount = null;
+            $cart->save();
+            Cart::collectTotals();
+
+            return response()->json([
+                'success' => true,
+                'message' => 'Member discount has been removed',
+            ]);
+        } catch (\Exception $e) {
+            return response()->json([
+                'success' => false,
+                'message' => 'Failed to remove member discount',
+                'error' => $e->getMessage(),
+            ], Response::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+}

+ 47 - 0
packages/Longyi/Member/src/Listeners/MemberHandler.php

@@ -0,0 +1,47 @@
+<?php
+
+namespace Longyi\Member\Listeners;
+
+use Webkul\Paypal\Payment\SmartButton;
+use Webkul\Sales\Repositories\OrderTransactionRepository;
+
+class MemberHandler
+{
+    protected const MEMBER_DISCOUNT_AMOUNT = 19.9;
+    /**
+     * Create a new listener instance.
+     *
+     * @return void
+     */
+    public function __construct(
+        protected SmartButton $smartButton,
+        protected OrderTransactionRepository $orderTransactionRepository
+    ) {}
+
+    /**
+     * Apply member discount to cart.
+     *
+     * @param  \Webkul\Checkout\Models\Cart  $cart
+     * @return void
+     */
+    public function applyMemberDiscount($cart)
+    {
+
+        if (!$cart || !auth()->guard('customer')->check()) {
+            return;
+        }
+        if ($cart->vip_plus_amount <= 0) {
+            return;
+        }
+        $discountAmountInCurrentCurrency = core()->convertPrice(self::MEMBER_DISCOUNT_AMOUNT);
+
+        $cart->vip_plus_amount = $discountAmountInCurrentCurrency;
+        $cart->base_vip_plus_amount = core()->convertToBasePrice($discountAmountInCurrentCurrency);
+
+        $cart->grand_total = max(0, round($cart->grand_total + $discountAmountInCurrentCurrency, 2));
+        $cart->base_grand_total = max(0, round($cart->base_grand_total + $cart->base_vip_plus_amount, 2));
+
+        $cart->save();
+    }
+
+}

+ 63 - 0
packages/Longyi/Member/src/Listeners/OrderPlacedHandler.php

@@ -0,0 +1,63 @@
+<?php
+
+namespace Longyi\Member\Listeners;
+
+use Longyi\Member\Models\MemberLog;
+use Webkul\Paypal\Payment\SmartButton;
+use Webkul\Sales\Repositories\OrderTransactionRepository;
+use Webkul\Customer\Models\Customer;
+use Carbon\Carbon;
+
+class OrderPlacedHandler
+{
+    protected const MEMBER_DISCOUNT_AMOUNT = 19.9;
+
+
+    protected const VIP_EXTENSION_DAYS = 180;
+    /**
+     * Create a new listener instance.
+     *
+     * @return void
+     */
+    public function __construct(
+        protected SmartButton $smartButton,
+        protected OrderTransactionRepository $orderTransactionRepository
+    ) {}
+
+    /**
+     * Apply member discount to cart.
+     *
+     * @param  \Webkul\Checkout\Models\Cart  $cart
+     * @return void
+     */
+    public function afterPlaceOrder($order)
+    {
+        if (!$order || !$order->customer_id) {
+            return;
+        }
+        if (!$order->vip_plus_amount || $order->vip_plus_amount <= 0) {
+            return;
+        }
+        $customer = Customer::find($order->customer_id);
+        if (!$customer) {
+            return;
+        }
+        $currentExpireDate = $customer->vip_expire_date ? Carbon::parse($customer->vip_expire_date) : now();
+        if ($currentExpireDate->isPast()) {
+            $newExpireDate = now()->addDays(self::VIP_EXTENSION_DAYS);
+        } else {
+            $newExpireDate = $currentExpireDate->addDays(self::VIP_EXTENSION_DAYS);
+        }
+        $customer->vip_expire_date = $newExpireDate;
+        $customer->save();
+        // 记录
+        MemberLog::create([
+            'customer_id' => $order->customer_id,
+            'order_id' => $order->increment_id,
+            'amount' => self::MEMBER_DISCOUNT_AMOUNT,
+            'type' => 1,
+            'expirationdate' => $newExpireDate
+        ]);
+    }
+
+}

+ 28 - 0
packages/Longyi/Member/src/Listeners/OrderViewHandler.php

@@ -0,0 +1,28 @@
+<?php
+
+namespace Longyi\Member\Listeners;
+
+use Webkul\Sales\Models\Order;
+
+class OrderViewHandler
+{
+    /**
+     * Handle order view information discount before event.
+     *
+     * @param  array  $data
+     * @return void
+     */
+    public function renderMemberInfo($viewRenderEventManager)
+    {
+        // 从当前路由参数中获取订单ID
+        $orderId = request()->route('id');
+
+        if (!$orderId) {
+            return;
+        }
+
+        // 查询订单
+        $order = Order::find($orderId);
+        echo view('member::customers.account.orders.member-info', compact('order'))->render();
+    }
+}

+ 98 - 0
packages/Longyi/Member/src/Listeners/VipDiscountHandler.php

@@ -0,0 +1,98 @@
+<?php
+
+namespace Longyi\Member\Listeners;
+
+use Carbon\Carbon;
+
+class VipDiscountHandler
+{
+    protected const VIP_DISCOUNT_PERCENTAGE = 0.05; // 5%
+
+    /**
+     * Apply VIP discount to cart.
+     *
+     * @param  \Webkul\Checkout\Models\Cart  $cart
+     * @return void
+     */
+    public function applyVipDiscount($cart)
+    {
+        if (!$cart || !auth()->guard('customer')->check()) {
+            return;
+        }
+        $isVip = false;
+        $customer = auth()->guard('customer')->user();
+        if ($cart->vip_plus_amount > 0 ||  $this->isValidVip($customer)) {
+            $isVip = true;
+        }
+        // 检查是否是有效 VIP
+        if (!$isVip) {
+            // 如果不是有效 VIP,清除可能存在的 VIP 折扣
+            if ($cart->vip_discount_amount && $cart->vip_discount_amount > 0) {
+                $this->removeVipDiscount($cart);
+            }
+            return;
+        }
+
+        // 计算购物车小计
+        $subTotal = $cart->sub_total;
+
+        if ($subTotal <= 0) {
+            return;
+        }
+
+        // 计算 5% 折扣
+        $discountAmountInCurrentCurrency = round($subTotal * self::VIP_DISCOUNT_PERCENTAGE, 2);
+        $baseDiscountAmount = core()->convertToBasePrice($discountAmountInCurrentCurrency);
+        // 应用新的 VIP 折扣
+        $cart->vip_discount_amount = $discountAmountInCurrentCurrency;
+        $cart->base_vip_discount_amount = $baseDiscountAmount;
+
+        $cart->grand_total = max(0, round($cart->grand_total - $discountAmountInCurrentCurrency, 2));
+        $cart->base_grand_total = max(0, round($cart->base_grand_total - $baseDiscountAmount, 2));
+
+        $cart->save();
+    }
+
+    /**
+     * Remove VIP discount from cart.
+     *
+     * @param  \Webkul\Checkout\Models\Cart  $cart
+     * @return void
+     */
+    public function removeVipDiscount($cart)
+    {
+        if (!$cart || !$cart->vip_discount_amount || $cart->vip_discount_amount <= 0) {
+            return;
+        }
+
+        // 1. 先取出要恢复的金额(在清空之前!)
+        $discountAmount = $cart->vip_discount_amount;
+        $baseDiscountAmount = $cart->base_vip_discount_amount;
+
+        // 2. 再清空字段
+        $cart->vip_discount_amount = null;
+        $cart->base_vip_discount_amount = null;
+
+        // 3. 恢复购物车总额
+        $cart->grand_total = round($cart->grand_total, 2);
+        $cart->base_grand_total = round($cart->base_grand_total, 2);
+
+        $cart->save();
+    }
+
+    /**
+     * Check if customer is a valid VIP.
+     *
+     * @param  \Webkul\Customer\Models\Customer  $customer
+     * @return bool
+     */
+    protected function isValidVip($customer): bool
+    {
+        if (!$customer->vip_expire_date) {
+            return false;
+        }
+
+        $expireDate = Carbon::parse($customer->vip_expire_date);
+        return !$expireDate->isPast();
+    }
+}

+ 39 - 0
packages/Longyi/Member/src/Models/MemberLog.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace Longyi\Member\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Longyi\Member\Contracts\MemberLog as MemberLogContract;
+
+class MemberLog extends Model implements MemberLogContract
+{
+    use HasFactory;
+
+    protected $table = 'member_log';
+    /**
+     * The attributes that are mass assignable.
+     *
+     * @var array
+     */
+    protected $fillable = [
+        'customer_id',
+        'order_id',
+        'type',
+        'amount',
+        'expirationdate'
+    ];
+
+    /**
+     * The attributes that should be cast.
+     *
+     * @var array
+     */
+    protected $casts = [
+        'customer_id' => 'integer',
+        'order_id' => 'integer',
+        'type' => 'integer',
+        'amount' => 'decimal:2',
+        'expirationdate' => 'date',
+    ];
+}

+ 9 - 0
packages/Longyi/Member/src/Models/MemberLogProxy.php

@@ -0,0 +1,9 @@
+<?php
+
+namespace Longyi\Member\Models;
+
+use Konekt\Concord\Proxies\ModelProxy;
+
+class MemberLogProxy extends ModelProxy
+{
+}

+ 33 - 0
packages/Longyi/Member/src/Providers/EventServiceProvider.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace Longyi\Member\Providers;
+
+use Illuminate\Support\Facades\Event;
+use Illuminate\Support\ServiceProvider;
+use Longyi\Member\Listeners\MemberHandler;
+use Longyi\Member\Listeners\VipDiscountHandler;
+
+class EventServiceProvider extends ServiceProvider
+{
+    /**
+     * Bootstrap services.
+     *
+     * @return void
+     */
+    public function boot()
+    {
+        Event::listen('checkout.cart.collect.totals.after', 'Longyi\Member\Listeners\MemberHandler@applyMemberDiscount');
+        Event::listen('checkout.cart.collect.totals.after', 'Longyi\Member\Listeners\VipDiscountHandler@applyVipDiscount');
+        Event::listen('bagisto.shop.checkout.onepage.summary.coupon.after', function($viewRenderEventManager) {
+            $viewRenderEventManager->addTemplate('member::components.vip-member-discount-cartsummary');
+        });
+        Event::listen('bagisto.shop.checkout.cart.summary.vip_member_discount.after', function($viewRenderEventManager) {
+            $viewRenderEventManager->addTemplate('member::components.member-discount-cartsummary');
+        });
+        Event::listen('bagisto.admin.sales.order.view.giftcard.before', function($viewRenderEventManager) {
+            $viewRenderEventManager->addTemplate('member::sales.orders.view');
+        });
+        Event::listen('checkout.order.save.after', 'Longyi\Member\Listeners\OrderPlacedHandler@afterPlaceOrder');
+        Event::listen('bagisto.shop.customers.account.orders.view.information.discount.after', 'Longyi\Member\Listeners\OrderViewHandler@renderMemberInfo');
+    }
+}

+ 55 - 0
packages/Longyi/Member/src/Providers/MemberServiceProvider.php

@@ -0,0 +1,55 @@
+<?php
+
+namespace Longyi\Member\Providers;
+
+use Illuminate\Support\ServiceProvider;
+use Illuminate\Support\Facades\Event;
+use Longyi\Member\Providers\EventServiceProvider;
+
+class MemberServiceProvider extends ServiceProvider
+{
+    /**
+     * Register services.
+     */
+    public function register(): void
+    {
+        $this->registerConfig();
+    }
+
+    /**
+     * Bootstrap services.
+     */
+    public function boot(): void
+    {
+        $this->loadMigrationsFrom(__DIR__ . '/../Database/Migrations');
+
+        $this->loadRoutesFrom(__DIR__ . '/../Routes/admin-routes.php');
+
+        $this->loadRoutesFrom(__DIR__ . '/../Routes/shop-routes.php');
+
+        $this->loadTranslationsFrom(__DIR__ . '/../Resources/lang', 'member');
+
+        $this->loadViewsFrom(__DIR__ . '/../Resources/views', 'member');
+
+        Event::listen('bagisto.admin.layout.head', function($viewRenderEventManager) {
+            $viewRenderEventManager->addTemplate('member::admin.layouts.style');
+        });
+        $this->app->register(EventServiceProvider::class);
+    }
+
+    /**
+     * Register package config.
+     *
+     * @return void
+     */
+    protected function registerConfig()
+    {
+        $this->mergeConfigFrom(
+            dirname(__DIR__) . '/Config/admin-menu.php', 'menu.admin'
+        );
+
+        $this->mergeConfigFrom(
+            dirname(__DIR__) . '/Config/acl.php', 'acl'
+        );
+    }
+}

+ 15 - 0
packages/Longyi/Member/src/Providers/ModuleServiceProvider.php

@@ -0,0 +1,15 @@
+<?php
+
+namespace Longyi\Member\Providers;
+
+use Konekt\Concord\BaseModuleServiceProvider;
+
+class ModuleServiceProvider extends BaseModuleServiceProvider
+{
+    /**
+     * Models.
+     *
+     * @var array
+     */
+    protected $models = [];
+}

+ 16 - 0
packages/Longyi/Member/src/Repositories/MemberLogRepository.php

@@ -0,0 +1,16 @@
+<?php
+
+namespace Longyi\Member\Repositories;
+
+use Webkul\Core\Eloquent\Repository;
+
+class MemberLogRepository extends Repository
+{
+    /**
+     * Specify model class name.
+     */
+    public function model(): string
+    {
+        return 'Longyi\Member\Contracts\MemberLog';
+    }
+}

+ 23 - 0
packages/Longyi/Member/src/Resources/assets/css/app.css

@@ -0,0 +1,23 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer components {
+    .temp-icon {
+        width: 48px;
+        height: 48px;
+        display: inline-block;
+        background-size: cover;
+        background-image: url("../images/icon-temp.svg");
+    }
+
+    .active {
+        .temp-icon {
+            background-image: url("../images/icon-temp-active.svg");
+        }
+
+        &.temp-icon {
+            background-image: url("../images/icon-temp-active.svg");
+        }
+    }
+}

+ 12 - 0
packages/Longyi/Member/src/Resources/assets/images/icon-temp-active.svg

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="48px" height="48px" viewBox="0 0 48 48" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
+    <title>Icon-Catalog-Active</title>
+    <desc>Created with Sketch.</desc>
+    <defs></defs>
+    <g id="Icon-Catalog-Active" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
+        <g transform="translate(9.000000, 7.000000)" stroke="#0041FF" stroke-width="2">
+            <rect id="Rectangle-2" x="0" y="0" width="30" height="34"></rect>
+        </g>
+    </g>
+</svg>

+ 0 - 0
packages/Longyi/Member/src/Resources/assets/images/icon-temp.svg


Некоторые файлы не были показаны из-за большого количества измененных файлов