Compare commits

..

67 Commits

Author SHA1 Message Date
masoodafar-web f743121eb2 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 9m6s
2026-10-12 16:41:15 +03:30
masoodafar-web 76d96c2544 feat(profile): temporarily disable gateway charge for discount wallet
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 11m10s
- Added an alert to inform users that the gateway charge for the discount wallet is currently disabled.
- Updated the Wallet.razor file to hide the charge button for the discount wallet, with a note for future restoration.

These changes enhance user experience by providing clear communication regarding the status of the wallet charging feature.
2026-10-12 16:40:47 +03:30
masoodafar-web b60d1da4b7 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 9m21s
2026-10-05 01:26:11 +03:30
masoodafar-web 016b259da0 feat(index): add configuration options for landing page sections
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m47s
- Introduced new settings in appsettings files to control the visibility of the steps and features sections on the landing page, allowing for dynamic content presentation.
- Updated Index.razor and Index.razor.cs to utilize these settings, enhancing user experience by enabling or disabling sections based on configuration.
2026-10-04 23:54:42 +03:30
masoodafar-web bb789e443e feat(index): enable conditional rendering for steps and features sections
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m21s
- Added support for conditional rendering of the steps and features sections on the landing page, controlled by the new `StepsEnabled` and `FeaturesEnabled` properties in the Index.razor.cs file.
- This enhancement allows for more dynamic content presentation based on user settings, improving the overall user experience.
2026-10-04 23:34:22 +03:30
masoodafar-web 0c1842cf02 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 11m31s
2026-10-04 23:02:04 +03:30
masoodafar-web 392e038913 chore(k8s): update kub-deploy workflow and frontoffice deployment configuration
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 10m11s
- Modified the kub-deploy.yml to remove cached images using containerd's ctr command, ensuring that both IfNotPresent and Always image pull policies work correctly.
- Updated frontoffice-deployment.yaml to set imagePullPolicy to Always, preventing stale node cache issues with the :latest tag.

These changes improve the deployment process and ensure that the latest images are always used in the staging environment.
2026-10-03 23:53:02 +03:30
masoodafar-web d7f25efce2 fix(index): remove unnecessary fade-in effect from TwentyCard promo section
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 11m13s
- Eliminated the fade-in effect from the MudPaper component in the TwentyCard credit promo section on the landing page, streamlining the visual presentation.

This change enhances the user experience by simplifying the design of the promotional section.
2026-10-03 22:43:53 +03:30
masoodafar-web 5246d8c1ba feat(index): add TwentyCard credit promo section and integrate portal link
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 9m23s
- Introduced a new promotional section for TwentyCard credit on the landing page, featuring details about the service and a button to access the TwentyCard portal.
- Updated the Index.razor.cs file to retrieve the portal URL from configuration and implemented the logic to open the portal in a new tab.
- Enhanced site.css with styling specific to the TwentyCard promo box.

These changes enhance user engagement by promoting the TwentyCard credit option and providing easy access to its portal.
2026-10-03 22:18:44 +03:30
masoodafar-web f5bfdba124 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 12m51s
2026-09-24 21:49:42 +03:30
masoodafar-web c2c07c6b89 chore(k8s): deploy staging apps to namespace kbs-stage
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 29s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 21:26:19 +03:30
masoodafar-web 764742dbe7 chore(infra): point staging registry to 185.252.31.44; keep prod K8S on 45.149.79.127
Build and Deploy to Production / build-and-deploy (push) Failing after 1s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 15:17:07 +03:30
masoodafar-web 955736d345 fix(copy): replace شبکه with سازمان and clarify inviter wording
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 16m3s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 22:49:41 +03:30
masoodafar-web ea357e9865 chore(staging): point registry and K8S to 185.252.31.44 after server migration 2026-09-22 22:18:43 +03:30
masoodafar-web a4061c041b fix(profile): update invitees description for clarity
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 15m47s
- Revised the text in the Invitees.razor file to clarify that registered invitees are distinct from the sales organization, improving user understanding of the invitee section.

This change enhances the clarity of the information presented to users on the profile invitees page.
2026-09-22 00:08:02 +03:30
masoodafar-web a108f89213 chore(config): update CreditPortalUrl in appsettings files and clean up .gitignore
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 7m20s
- Changed the CreditPortalUrl in appsettings.json, appsettings.Production.json, and appsettings.Staging.json to point to the new user panel URL.
- Removed an unnecessary blank line from the .gitignore file to maintain consistency.

These updates ensure correct configuration for the TwentyCard integration and improve the project's file management.
2026-09-21 21:32:15 +03:30
masoodafar-web c71062decf refactor(profile): clean up Invitees.razor by removing unused using directive
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 5m29s
- Removed the unused DateTimeConverterCL using directive from the Invitees.razor file, streamlining the code and improving readability.

This change enhances the maintainability of the profile invitees page by eliminating unnecessary dependencies.
2026-09-21 00:44:15 +03:30
masoodafar-web c815f8d358 refactor(project): update Protobuf package management and add conditional ProjectReference
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 6m22s
- Removed the direct PackageReference for Foursat.CMSMicroservice.Protobuf and added a conditional ProjectReference to the CMSMicroservice.Protobuf project for local development.
- This change allows for direct communication with the CMS when the project is available locally, while still maintaining the NuGet package reference for CI/Docker environments.

These updates improve the flexibility of the project setup for different development scenarios.
2026-09-20 23:35:11 +03:30
masoodafar-web 27a3ea76c5 feat(profile): add invitees section and inviter mobile functionality
- Introduced a new section in the Profile hub for viewing invitees, enhancing user engagement and tracking of referrals.
- Added buttons for accessing the invitees page from the Profile index, improving navigation.
- Enhanced the AuthDialog and PhoneVerifyForm components to support inviter mobile input, allowing users to specify who referred them.
- Updated RouteConstants to include a new route for invitees, ensuring proper navigation within the application.

These changes improve the user experience by facilitating better referral tracking and enhancing the profile management features.
2026-09-20 23:33:18 +03:30
masoodafar-web 0d0ca252a6 fix(project): restore PackageReference and comment out ProjectReference for Protobuf
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 7m42s
- Reinstated the PackageReference for Foursat.CMSMicroservice.Protobuf version 0.0.212, ensuring the project can utilize the necessary protobuf functionalities.
- Commented out the ProjectReference for the CMSMicroservice.Protobuf project to prevent build issues during development.

These changes ensure proper package management and maintain project stability during development.
2026-09-20 22:41:49 +03:30
masoodafar-web aedf312812 feat(payment): integrate TwentyCard payment option and enhance checkout flow
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 5m6s
- Added a new payment method for TwentyCard in the checkout process, allowing users to select it alongside direct payment options.
- Implemented the logic for processing TwentyCard payments, including user feedback and dialog interactions for payment confirmation.
- Updated the appsettings files to include configuration for the new payment method.
- Enhanced the user interface in the PackagePurchaseDialog to reflect the new payment option and its requirements.

These changes improve the payment flexibility in the checkout process, providing users with additional options for completing their transactions.
2026-09-20 22:17:39 +03:30
masoodafar-web 690aaa714b Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 4m15s
2026-09-04 07:26:01 +03:30
masoodafar-web 8252e408b6 feat(organization-chart): update registration date handling and styling
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 3m41s
- Modified the OrganizationChart component to convert and display the registration date in Jalali format, enhancing user understanding of the date information.
- Updated the NetworkMembershipDtos class to include a comment in Persian for the JoinedAt property, clarifying its purpose.
- Adjusted the NetworkMembershipService to utilize the UserCreated date when available, improving data accuracy.
- Introduced new CSS styles for the registration date display, ensuring better visibility and presentation in the organization chart.

These changes enhance the clarity and usability of the organization chart by providing accurate registration date information in a user-friendly format.
2026-09-04 06:48:24 +03:30
masoodafar-web 186abe042f feat(network-statistics): add total members and leg count properties with localization
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 4m23s
- Introduced new properties for total members, left leg count, and right leg count in the NetworkStatisticsDto class to enhance data representation.
- Added localization comments in Persian for better understanding of the new properties.
- Updated the .gitignore file to ensure consistency with the latest Visual Studio ignore patterns.

These changes improve the clarity and usability of the network statistics, providing a more comprehensive view of member distribution.
2026-09-04 04:14:36 +03:30
masoodafar-web c6089f8dc3 refactor(organization-chart): update terminology and documentation for clarity
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 7m35s
- Changed terminology in the organization chart from "امتیاز" to "پکیج" for gold and silver packages to enhance clarity.
- Updated comments and documentation in the codebase to reflect the new terminology and improve understanding of the statistics being displayed.
- Adjusted the CHANGELOG to indicate the move of documentation to totalDoc.

These changes improve the overall clarity and usability of the organization chart and related documentation.
2026-08-28 18:56:34 +03:30
masoodafar-web 101687f9db feat(organization-chart): update statistics display and improve layout
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 4m44s
- Updated the organization chart to include additional statistics such as left and right leg counts, tree depth, and weaker leg identification for enhanced user insights.
- Improved the layout and styling of the statistics bar for better visibility and user experience.
- Adjusted the CSS for the organization chart to refine the overall presentation and responsiveness.

These changes enhance the clarity and usability of the organization chart, providing users with essential metrics at a glance.
2026-08-27 21:30:06 +03:30
masoodafar-web 307302e33d feat(organization-chart): enhance organization chart with leg totals and styling improvements
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 6m9s
- Updated the organization chart to include gold and silver leg totals for better visibility of user metrics.
- Modified the CSS for the chart to improve layout and styling, ensuring a more user-friendly interface.
- Adjusted JavaScript to dynamically display leg scores, enhancing the overall interactivity of the chart.

These changes improve the clarity and usability of the organization chart, providing users with essential metrics at a glance.
2026-08-25 22:39:30 +03:30
masoodafar-web c243b59113 feat(address): enhance address management in checkout and summary components
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 6m48s
- Updated the Checkout and CheckoutSummary components to allow users to add new addresses directly from the interface, improving accessibility and user experience.
- Introduced dialog functionality for adding and editing addresses, streamlining the address management process.
- Removed the calculation of order PV from the OrderDetail component, simplifying the order details view and improving performance.

These changes enhance the usability of the checkout process by making address management more intuitive and efficient.
2026-08-25 21:40:38 +03:30
masoodafar-web 58037f0b49 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 5m9s
2026-08-24 00:10:52 +03:30
masoodafar-web aae4b2693d fix(checkout): add missing JSInterop import for enhanced interactivity
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 3m47s
- Included the Microsoft.JSInterop namespace in the Checkout.razor.cs file to enable JavaScript interop functionality.
- This addition prepares the Checkout component for future enhancements that may require interaction with JavaScript libraries or functions.

These changes improve the potential for interactive features in the checkout process, enhancing user experience.
2026-08-23 23:46:35 +03:30
masoodafar-web 61fd805c75 feat(profile): enhance withdrawal request processing with IBAN normalization
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 4m8s
- Implemented a new method to normalize Iranian IBANs, ensuring they are formatted correctly before processing withdrawal requests.
- Added validation to provide user feedback if the IBAN format is invalid, improving the user experience during withdrawal submissions.
- Updated the withdrawal request logic to utilize the normalized IBAN, enhancing the reliability of the withdrawal process.

These changes improve the accuracy and usability of the withdrawal request feature, ensuring users can submit valid IBANs effectively.
2026-08-23 23:01:39 +03:30
masoodafar-web ef6d233bbc refactor(checkout): simplify address management and improve package selection logic
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 2m45s
- Removed the address selection UI and related logic from the Checkout page to streamline the user experience.
- Updated the payment validation to only require package selection, enhancing clarity for users.
- Adjusted the loading of addresses in related components based on user authentication status, ensuring addresses are only loaded when available.

These changes improve the overall usability of the checkout process by focusing on essential selections and reducing unnecessary complexity.
2026-08-23 22:32:47 +03:30
masoodafar-web 3d84e726db Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 6m27s
2026-08-09 02:33:27 +03:30
masoodafar-web 13091849cf feat(wallet): update ChargeCreditWallet component for improved magic ceiling consent handling
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m1s
- Enhanced the user interface to provide clearer instructions and options for charging without a magic multiplier when the magic wallet ceiling is full.
- Removed the previous consent prompting logic and replaced it with direct buttons for user actions.
- Updated methods to streamline the consent process and improve navigation based on user choices.

These changes enhance user experience by simplifying the consent flow and providing immediate options for wallet charging.
2026-08-09 00:11:49 +03:30
masoodafar-web 8e98c50bdf feat(wallet): enhance ChargeCreditWallet and Wallet components with magic ceiling consent logic
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 5m29s
- Added new logic to handle user consent for charging without a magic multiplier when the magic wallet ceiling is full.
- Updated the ChargeCreditWallet component to display alerts and prompts based on the user's magic wallet status and consent.
- Enhanced the Wallet component to conditionally render buttons and alerts related to the magic wallet ceiling status.
- Improved user feedback in the InsufficientCreditDialog to guide users on charging options based on their wallet status.

These changes enhance the user experience by providing clear instructions and options for charging wallets under specific conditions.
2026-08-08 23:59:36 +03:30
masoodafar-web 6123e47968 feat(wallet): enhance ChargeCreditWallet and Wallet components with magic wallet functionality
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 5m28s
- Added logic to handle redirection and alerts for users with a magic wallet status in the ChargeCreditWallet component.
- Updated the Wallet component to conditionally render buttons and alerts based on the user's magic wallet status.
- Implemented a method to check the magic wallet status and provide user feedback regarding wallet charging options.

These changes improve user experience by clearly guiding users on the appropriate wallet charging methods based on their current wallet status.
2026-08-08 22:22:03 +03:30
masoodafar-web acad1a719f feat(index): add conditional rendering for testimonials section
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 6m31s
- Implemented a check to display the testimonials section only when there are testimonials available, enhancing the user experience by preventing empty sections.
- Updated the logic for loading testimonials from settings to handle cases where the testimonials list may be null, ensuring robustness in data handling.

These changes improve the layout and presentation of the Index page, providing users with relevant content based on available testimonials.
2026-08-07 18:39:49 +03:30
masoodafar-web c7e6d01c11 feat(orders): enhance order display and interaction for improved user experience
- Refactored the Orders page to improve the layout and presentation of order information, including a new responsive design for mobile and desktop views.
- Added a button to navigate to the store when no orders are present, enhancing user engagement.
- Updated the order details display to include payment and delivery status with color-coded chips for better visual feedback.
- Introduced helper methods for formatting prices and dates, improving code readability and maintainability.

These changes significantly enhance the usability and clarity of the order management interface, providing users with a more intuitive experience.
2026-08-07 18:28:38 +03:30
masoodafar-web c764614d26 feat(order-tracking): enhance tracking steps logic and delivery status handling
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 5m52s
- Refactored the BuildTrackingSteps method to improve the logic for displaying order tracking steps based on various delivery statuses.
- Added handling for new delivery statuses: "آماده تحویل در دفتر" and "مرجوع شده".
- Updated the payment status step to consolidate descriptions based on payment completion.
- Enhanced the overall user experience by providing clearer tracking information for different order states.

These changes improve the clarity and functionality of the order tracking feature, ensuring users receive accurate updates on their order status.
2026-08-07 17:35:05 +03:30
masoodafar-web 9203dc7924 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 6m24s
2026-08-07 14:38:22 +03:30
masoodafar-web b64d99c5b3 feat(profile): enhance ChargeCreditWallet and Wallet components with club membership checks
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 4m42s
- Added logic to display alerts and buttons based on club membership status in the ChargeCreditWallet and Wallet components.
- Implemented user authentication checks to determine if the user is a club member and has purchased a package before allowing wallet charging.
- Updated UI to guide users on purchasing packages or signing club contracts if they are not eligible to charge their wallet.

These changes improve user experience by providing clear instructions and preventing unauthorized wallet charges.
2026-08-06 23:59:42 +03:30
masoodafar-web 4989cd00e0 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Failing after 16m3s
2026-07-26 00:52:30 +03:30
masoodafar-web a708727241 feat: enhance product listing and navigation functionality
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
- Added a wrapper div for product items in the DiscountStore and Store pages to improve layout consistency.
- Implemented state management for URL synchronization and scroll restoration in both product components.
- Refactored product loading logic to streamline data retrieval and improve user experience when navigating between product pages.

These changes enhance the overall functionality and user experience of the product listing pages, ensuring better state management and navigation.
2026-07-26 00:17:55 +03:30
masoodafar-web bed35935ad Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Failing after 17m17s
2026-07-15 22:02:31 +03:30
masoodafar-web 2339ca99b4 fix: correct Persian labels in OrganizationChart component
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
- Updated the labels for left and right legs in the OrganizationChart component from "پای چپ" and "پای راست" to "سازمان چپ" and "سازمان راست" for improved clarity and accuracy.
- Removed unnecessary whitespace in the FrontOffice.Main.csproj file.

These changes enhance the user interface by ensuring the terminology used in the OrganizationChart is more appropriate for the context.
2026-07-15 21:51:38 +03:30
masoodafar-web ceaaac3e23 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Failing after 18m42s
2026-07-03 06:04:56 +03:30
masoodafar-web 7f5a4f2c37 feat: enhance SEO and sitemap functionality
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
- Updated the sitemap generation to return a byte array instead of a string for improved performance.
- Added new SEO metadata for categories and store chooser routes to enhance search engine visibility.
- Implemented redirects for legacy wishlist URLs to the homepage, improving user experience.
- Updated robots.txt to disallow indexing of specific paths, ensuring better control over search engine crawling.

These changes significantly improve the SEO capabilities and sitemap handling of the application, enhancing both performance and user navigation.
2026-07-03 06:04:44 +03:30
masoodafar-web db35814ce3 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Has been cancelled
2026-07-03 05:43:14 +03:30
masoodafar-web 06100ca6dd fix: update sitemap generation to use XmlWriter for improved XML handling
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
- Changed the encoding in the XML declaration from "UTF-8" to "utf-8" for consistency.
- Replaced StringWriter with XmlWriter to enhance XML document writing and ensure proper encoding settings.
- Updated the return statement to convert the MemoryStream to a UTF-8 string, improving the efficiency of the sitemap generation process.

These changes enhance the reliability and correctness of the sitemap generation, ensuring better compliance with XML standards.
2026-07-03 05:30:23 +03:30
masoodafar-web 7a5a2110c6 feat: add SEO configuration for production environment
Build and Deploy to Production / build-and-deploy (push) Failing after 19m58s
- Introduced SEO settings in appsettings.Production.json, including SiteUrl, SiteName, and DefaultDescription.
- These additions enhance the application's search engine optimization capabilities, improving visibility and user engagement.
2026-07-01 21:48:49 +03:30
masoodafar-web 8dbc64cd86 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Has been cancelled
2026-07-01 21:45:43 +03:30
masoodafar-web 2573729db5 feat: implement SEO enhancements and sitemap generation
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
- Added SEO configuration settings in appsettings for both development and staging environments, including site URL, name, and default description.
- Registered SEO-related services in the dependency injection container, including `SeoMetadataProvider` and `SitemapGenerator`.
- Updated the main program to configure SEO settings and map a new endpoint for generating the sitemap.
- Enhanced the `_Host.cshtml` page to utilize SEO metadata, including title, description, and Open Graph tags, improving search engine visibility and user engagement.

These changes significantly enhance the SEO capabilities of the application, ensuring better indexing and presentation in search results.
2026-07-01 21:45:24 +03:30
masoodafar-web deadd197e6 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Has been cancelled
2026-07-01 21:31:49 +03:30
masoodafar-web 5d58d354d7 feat: enhance product detail loading and initialization logic
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 6m50s
- Introduced an `_initialized` flag to manage product loading state more effectively.
- Updated `OnParametersSetAsync` to conditionally load product details based on the initialization state and valid product ID.
- Refactored product retrieval logic in `ProductService` to handle invalid IDs and improve fallback mechanisms for fetching product details.

These changes improve the reliability and performance of the product detail component, ensuring that product information is loaded correctly and efficiently.
2026-07-01 21:21:57 +03:30
masoodafar-web 671c8407e5 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Has been cancelled
2026-06-30 02:50:03 +03:30
masoodafar-web 454d27f37e feat: add new sections for steps, features, and stats on the landing page
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
- Introduced a "How It Works" section with a three-step guide to enhance user onboarding.
- Added a "Features" section showcasing six key features with icons to highlight the platform's benefits.
- Implemented a "Stats" section displaying counter cards for key metrics, improving the overall presentation of information on the landing page.

These changes enhance the user experience by providing clear and engaging content that guides users through the platform's offerings.
2026-06-30 02:49:50 +03:30
masoodafar-web 1956dcc5f4 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Has been cancelled
2026-06-30 02:28:13 +03:30
masoodafar-web 37c7cebf92 feat: update product service to support in-stock filtering and enhance top-selling product retrieval
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 23m11s
- Modified `GetTopSellingAsync` methods in `ProductService` and `DiscountProductService` to include an optional `inStock` parameter for filtering products based on availability.
- Introduced a constant for the top product count in `Index.razor.cs` to improve maintainability and readability.
- Updated the product retrieval logic in `Index.razor.cs` to utilize the new in-stock filtering feature.

These changes enhance the product listing functionality by allowing users to view only in-stock top-selling products, improving the overall shopping experience.
2026-06-30 00:17:30 +03:30
masoodafar-web d59d829d5f Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Failing after 17m21s
2026-06-26 19:30:51 +03:30
masoodafar-web 81d139cdab fix(profile): standardize JSRuntime usage in ChargeCreditWallet component
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 27m33s
- Updated references to `jsRuntime` to `JSRuntime` for consistency across the ChargeCreditWallet component.
- Improved the clarity of the code by ensuring uniform naming conventions in the handling of return URLs.

These changes enhance code readability and maintainability in the ChargeCreditWallet functionality.
2026-06-26 18:47:13 +03:30
masoodafar-web d44e99f4ab feat(profile): enhance ChargeCreditWallet with return URL handling and continue shopping button
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 22m27s
- Added support for return URL handling in ChargeCreditWallet to improve user experience after payment.
- Introduced a "Continue Shopping" button that allows users to navigate back to their previous shopping session after successfully charging their wallet.
- Updated the component to manage pending return URLs and ensure they are saved and retrieved correctly.

These changes enhance the functionality of the ChargeCreditWallet page, providing a smoother transition for users post-transaction.
2026-06-26 18:17:19 +03:30
masoodafar-web cd835ae6f9 feat(wallet): add credit wallet charge functionality and update payment callback
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
- Introduced methods for initiating and verifying credit wallet charges in the WalletService.
- Updated PaymentCallback.razor to handle credit wallet charge verification.
- Added a button for charging the credit wallet in Wallet.razor.
- Updated route constants to include the new credit wallet charge route.

These changes enhance the wallet functionality by allowing users to charge their credit wallets and verify transactions seamlessly.
2026-06-26 16:13:18 +03:30
masoodafar-web 3c216feb32 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Failing after 15m3s
2026-06-19 02:38:15 +03:30
masoodafar-web 7f8f99d4eb feat: enhance organization chart data mapping for improved clarity
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 17m19s
Updated the organization chart component to explicitly map network node properties to a flat array with camelCase keys. This change aligns the data structure with the BackOffice admin chart, enhancing consistency and clarity in the displayed information.
2026-06-19 01:56:54 +03:30
masoodafar-web 8766c29a5a fix(k8s): update imagePullPolicy to Always in frontoffice-deployment.yaml
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 20m28s
Changed the imagePullPolicy for the frontoffice container to Always to ensure that the latest image is always pulled from the registry during deployment, improving the consistency of the application updates. Additionally, added a cleanup step in the kub-deploy workflow to remove the latest image from the local cache after deployment.
2026-06-19 00:13:02 +03:30
masoodafar-web 8080b75f05 fix(k8s): change imagePullPolicy to IfNotPresent in frontoffice-deployment.yaml
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 19m56s
Updated the imagePullPolicy for the frontoffice container to IfNotPresent to optimize image pulling behavior, ensuring that images are only pulled when not already present on the node.
2026-06-14 00:08:23 +03:30
masoodafar-web 34c5c508e0 feat: update package version and enhance network membership DTOs
- Updated the version of the Foursat.CMSMicroservice.Protobuf package from 0.0.197 to 0.0.199.
- Added a new property `PackageName` to `NetworkNodeDto` and `FlatNetworkNodeDto` for better representation of membership packages.
- Updated mapping in `NetworkMembershipService` to include the new `PackageName` property.
- Enhanced the org chart display by adding a badge for the package name in the UI.

These changes improve the clarity and functionality of the network membership features.
2026-06-11 23:33:40 +03:30
80 changed files with 3370 additions and 655 deletions
+13 -8
View File
@@ -6,15 +6,15 @@ on:
- kub-stage
env:
REGISTRY: 194.5.195.53:30080
REGISTRY: 185.252.31.44:30080
IMAGE_NAME: admin/frontoffice
K8S_SERVER: 194.5.195.53
K8S_SERVER: 185.252.31.44
jobs:
build-and-deploy:
runs-on: ubuntu-latest
container:
image: 194.5.195.53:32082/docker-sshpass:latest
image: 185.252.31.44:32082/docker-sshpass:latest
options: --privileged
steps:
- name: Start Docker daemon
@@ -22,7 +22,7 @@ jobs:
mkdir -p /etc/docker
cat > /etc/docker/daemon.json << 'DAEMON'
{
"insecure-registries": ["194.5.195.53:30080", "194.5.195.53:32500", "194.5.195.53:32082"]
"insecure-registries": ["185.252.31.44:30080", "185.252.31.44:32500", "185.252.31.44:32082"]
}
DAEMON
echo "🚀 Starting Docker daemon..."
@@ -52,7 +52,7 @@ jobs:
- name: Login to Docker registries
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login 194.5.195.53:32082 -u admin --password-stdin
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login 185.252.31.44:32082 -u admin --password-stdin
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u admin --password-stdin
- name: Build Docker Image
@@ -73,9 +73,14 @@ jobs:
# Apply manifests and restart
sshpass -e ssh -o StrictHostKeyChecking=no root@${{ env.K8S_SERVER }} "
kubectl apply -f /tmp/frontoffice-deployment.yaml &&
kubectl rollout restart deployment/frontoffice &&
kubectl rollout status deployment/frontoffice --timeout=180s &&
kubectl apply -n kbs-stage -f /tmp/frontoffice-deployment.yaml &&
# Drop cached :latest so IfNotPresent/Always both pick the new digest
# (crictl is broken on this host — use containerd ctr)
ctr -n k8s.io images ls -q | grep -E 'frontoffice(:latest|@)' | while read -r ref; do
ctr -n k8s.io images rm \"\$ref\" 2>/dev/null || true
done
kubectl -n kbs-stage rollout restart deployment/frontoffice &&
kubectl -n kbs-stage rollout status deployment/frontoffice --timeout=180s &&
rm -f /tmp/frontoffice-deployment.yaml
"
echo "✅ Deployed!"
+4 -4
View File
@@ -6,7 +6,7 @@ on:
- production
env:
REGISTRY: 194.5.195.53:30080
REGISTRY: 185.252.31.44:30080
IMAGE_NAME: admin/frontoffice
K8S_SERVER: 45.149.79.127
@@ -14,7 +14,7 @@ jobs:
build-and-deploy:
runs-on: ubuntu-latest
container:
image: 194.5.195.53:32082/docker-sshpass:latest
image: 185.252.31.44:32082/docker-sshpass:latest
options: --privileged
steps:
- name: Start Docker daemon
@@ -22,7 +22,7 @@ jobs:
mkdir -p /etc/docker
cat > /etc/docker/daemon.json << 'DAEMON'
{
"insecure-registries": ["194.5.195.53:30080", "194.5.195.53:32500", "194.5.195.53:32082"]
"insecure-registries": ["185.252.31.44:30080", "185.252.31.44:32500", "185.252.31.44:32082"]
}
DAEMON
echo "🚀 Starting Docker daemon..."
@@ -50,7 +50,7 @@ jobs:
- name: Login to Docker registries
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login 194.5.195.53:32082 -u admin --password-stdin
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login 185.252.31.44:32082 -u admin --password-stdin
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u admin --password-stdin
- name: Build Docker Image
+1
View File
@@ -1,3 +1,4 @@
# Docs moved to totalDoc
See [totalDoc/INDEX.md](../../totalDoc/INDEX.md) for all documentation.
+262
View File
@@ -0,0 +1,262 @@
# سرویس‌های FrontOffice (سمت مشتری)
این سند تمام سرویس‌هایی را که **FrontOffice** (فرانت مشتری) به **CMS** از طریق gRPC Gateway (`GW_URL`) فراخوانی می‌کند، به ترتیب **Use Case** فهرست می‌کند.
- **مسیر کد سرویس‌ها:** `FrontOffice/src/FrontOffice.Main/Utilities/`
- **ثبت gRPC:** `FrontOffice/src/FrontOffice.Main/ConfigureServices.cs`
- **تاریخ:** ۱۴۰۵/۰۳/۱۷
---
## ۱. احراز هویت و ثبت‌نام
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 1.1 | `AuthService` — `RefreshTokenAsync` | `UserContract.RefreshToken` | `/profile` (`Profile/Index`) | تمدید JWT پس از بازگشت از درگاه پرداخت |
| 1.2 | `AuthService` — `InitUserAuthInfo` / claims | — (local JWT parse) | سراسری | خواندن UserId، نام، کدملی، وضعیت قرارداد/پکیج/باشگاه از توکن |
| 1.3 | `UserContract.CreateNewOtpToken` | `UserContract` | `Shared/AuthDialog` | ارسال OTP ورود/ثبت‌نام با موبایل |
| 1.4 | `UserContract.VerifyOtpToken` | `UserContract` | `Shared/AuthDialog` | تأیید OTP و دریافت JWT |
| 1.5 | `UserContract.CreateNewOtpToken` (Purpose=`signContract`) | `UserContract` | `/register` (`RegisterWizard`) | ارسال OTP برای امضای قرارداد اصلی |
| 1.6 | `UserContract.AcceptContract` | `UserContract` | `/register` (`RegisterWizard`) | ثبت امضای قرارداد اصلی + دریافت توکن به‌روز |
| 1.7 | `UserContract.GetUserForCustomer` | `UserContract` | `/register`, `/profile`, `/profile/personal`, `/profile/settings` | بارگذاری پروفایل موجود کاربر |
| 1.8 | `UserContract.UpdateCustomerProfile` | `UserContract` | `/register`, `/profile`, `/profile/personal` | ذخیره نام، نام‌خانوادگی، کدملی و… |
| 1.9 | `AuthDialogService.ShowAuthDialogAsync` | — (UI) | فروشگاه، سبد، checkout | نمایش دیالوگ ورود برای مهمان |
| 1.10 | `GuestActionGate` | — (wrapper روی Auth) | `/`, `/products`, `/product/{id}`, Discount Store | جلوگیری از افزودن به سبد بدون لاگین |
---
## ۲. پروفایل و آدرس
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 2.1 | `UserContract.GetUserForCustomer` | `UserContract` | `/profile`, `/profile/personal`, `/profile/settings` | نمایش و ویرایش اطلاعات شخصی |
| 2.2 | `UserContract.GetUser` | `UserContract` | `/profile/hub`, `Club/ActivationSection` | اطلاعات کامل کاربر (هاب پروفایل / پس از فعال‌سازی باشگاه) |
| 2.3 | `UserContract.UpdateUser` | `UserContract` | `/profile/settings` | به‌روزرسانی تنظیمات حساب |
| 2.4 | `UserAddressContract.GetCustomerAddresses` | `UserAddressContract` | `/profile`, `/profile/addresses`, `/checkout`, Store/Discount checkout | لیست آدرس‌های تحویل |
| 2.5 | `UserAddressContract.CreateCustomerAddress` | `UserAddressContract` | `Profile/Components/AddAddressDialog` | ثبت آدرس جدید |
| 2.6 | `UserAddressContract.UpdateCustomerAddress` | `UserAddressContract` | `Profile/Components/EditAddressDialog` | ویرایش آدرس |
| 2.7 | `UserAddressContract.DeleteCustomerAddress` / `DeleteUserAddress` | `UserAddressContract` | `/profile/addresses`, `/profile` | حذف آدرس |
| 2.8 | `UserAddressContract.SetCustomerDefaultAddress` / `SetAddressAsDefault` | `UserAddressContract` | `/profile/addresses`, `/profile`, `/checkout` | تنظیم آدرس پیش‌فرض |
| 2.9 | `CityContract.GetAllCitiesByFilter` | `CityContract` | `AddAddressDialog`, `EditAddressDialog` | جستجو و انتخاب شهر/استان |
---
## ۳. پکیج و خرید عضویت
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 3.1 | `PackageService.GetAllPackagesAsync` | `PackageContract.GetCustomerPackages` | `/packages`, `/my-packages`, `/`, `/checkout`, `PackagePurchaseDialog`, `ClubMembershipContractDialog`, Commission pages | لیست پکیج‌های قابل خرید |
| 3.2 | `PackageService.GetPackageByIdAsync` | `PackageContract.GetCustomerPackageDetails` | (از طریق wrapper؛ صفحه جزئیات مستقیم RPC دارد) | جزئیات یک پکیج |
| 3.3 | `PackageContract.GetCustomerPackageDetails` | `PackageContract` | `/package/{id}` (`PackageDetail`) | جزئیات، ویژگی‌ها و مشخصات پکیج |
| 3.4 | `PackageService.GetUserPackageStatusAsync` | `PackageContract.GetUserPackageStatus` | `/packages`, `/my-packages`, `/profile` | وضعیت خرید پکیج، باشگاه، موجودی کیف پول |
| 3.5 | `PackageContract.CustomerPurchasePackage` | `PackageContract` | `/profile` (`ProcessDirectPayment`), `/checkout` | شروع پرداخت درگاهی (Zarinpal) برای خرید پکیج |
| 3.6 | `PackageContract.CustomerVerifyPackagePurchase` | `PackageContract` | `/profile/payment-callback?type=package` | تأیید پرداخت پکیج پس از بازگشت از درگاه |
| 3.7 | `PackagePurchaseDialog` | — (UI + `PackageService`) | `/profile`, `/my-packages` | انتخاب پکیج و روش پرداخت (درگاه / Daya) |
| 3.8 | Daya Loan (external URL) | — | `/profile` | هدایت به `dayadiamond.ir` برای خرید اقساطی (بدون RPC) |
---
## ۴. کیف پول، شارژ و برداشت
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 4.1 | `WalletService.GetBalancesAsync` | `UserWalletContract.GetCustomerWallet` | `/profile`, `/profile/wallet`, Store checkout, PaymentCallback | موجودی اصلی، تخفیفی و شبکه |
| 4.2 | `WalletService.GetTransactionsAsync` | `UserWalletContract.GetCustomerWalletHistory` | `/profile/wallet` | تاریخچه تراکنش‌های کیف پول |
| 4.3 | `WalletService.GetMagicWalletStatusAsync` | `UserWalletContract.GetMagicWalletStatus` | `/profile`, `/profile/magic-wallet`, `/my-packages` | وضعیت کیف پول جادویی (Magic) |
| 4.4 | `WalletService.InitiateMagicChargeAsync` | `UserWalletContract.InitiateMagicCharge` | `/profile/magic-wallet` | شروع شارژ کیف پول جادویی → درگاه |
| 4.5 | `WalletService.VerifyMagicChargeAsync` | `UserWalletContract.VerifyMagicCharge` | `/profile/payment-callback?type=magic-wallet` | تأیید شارژ Magic پس از درگاه |
| 4.6 | `WalletService.InitiateDiscountChargeAsync` | `UserWalletContract.InitiateDiscountCharge` | `/profile/charge-discount-wallet` | شروع شارژ کیف پول تخفیفی → درگاه |
| 4.7 | `WalletService.VerifyDiscountChargeAsync` | `UserWalletContract.VerifyDiscountCharge` | `/profile/payment-callback?type=discount-wallet` | تأیید شارژ کیف تخفیفی |
| 4.8 | `WalletService.InitiateCreditChargeAsync` | `UserWalletContract.InitiateCreditCharge` | `/profile/charge-credit-wallet` | شروع شارژ کیف پول اصلی → درگاه |
| 4.9 | `WalletService.VerifyCreditChargeAsync` | `UserWalletContract.VerifyCreditCharge` | `/profile/payment-callback?type=credit-wallet` | تأیید شارژ کیف اصلی |
| 4.10 | `WalletService.GetWithdrawalsAsync` | `UserWalletContract.GetCustomerWithdrawals` | `/profile/withdrawal-requests` | لیست درخواست‌های برداشت |
| 4.11 | `WalletService.GetWithdrawalSettingsAsync` | `UserWalletContract.GetCustomerWithdrawalSettings` | `/profile/withdrawal-requests` | حداقل مبلغ برداشت |
| 4.12 | `WalletService.RequestWithdrawalAsync` | `UserWalletContract.CustomerWithdrawBalance` | `/profile/withdrawal-requests` | ثبت درخواست برداشت پاداش (با payoutId کمیسیون) |
| 4.13 | `CommissionService.GetWithdrawablePayoutsAsync` | `CommissionContract.GetMyCommissionPayouts` | `/profile/withdrawal-requests` | پاداش‌های قابل برداشت برای انتخاب |
---
## ۵. Callback پرداخت (مشترک)
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 5.1 | `PackageContract.CustomerVerifyPackagePurchase` | `PackageContract` | `/profile/payment-callback` (type=package) | تأیید خرید پکیج |
| 5.2 | `WalletService.VerifyMagicChargeAsync` | `UserWalletContract` | `/profile/payment-callback` (type=magic-wallet) | تأیید شارژ Magic |
| 5.3 | `WalletService.VerifyDiscountChargeAsync` | `UserWalletContract` | `/profile/payment-callback` (type=discount-wallet) | تأیید شارژ تخفیفی |
| 5.4 | `WalletService.VerifyCreditChargeAsync` | `UserWalletContract` | `/profile/payment-callback` (type=credit-wallet) | تأیید شارژ کیف اصلی |
| 5.5 | `DiscountOrderService.VerifyDiscountOrderPaymentAsync` | `DiscountOrderContract.CustomerVerifyDiscountOrderPayment` | `/profile/payment-callback` (type=discount-order) | تأیید پرداخت سفارش فروشگاه تخفیفی |
| 5.6 | `UserContract.GetUserForCustomer` + `AuthService.InitUserAuthInfo` | `UserContract` | `/profile/payment-callback` | به‌روزرسانی claims کاربر پس از پرداخت موفق |
---
## ۶. فروشگاه اصلی (Store)
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 6.1 | `ProductService.GetProductsPagedAsync` | `ProductsContract.GetProductsForCustomer` | `/products` | لیست محصولات با فیلتر/صفحه‌بندی |
| 6.2 | `ProductService.GetTopSellingAsync` | `ProductsContract` | `/` (صفحه اصلی) | پرفروش‌ترین محصولات |
| 6.3 | `ProductService.GetByIdAsync` | `ProductsContract.GetProductByIdForCustomer` | `/product/{id}` | جزئیات محصول + گالری |
| 6.4 | `CategoryService.GetAllAsync` / `GetByIdAsync` | `CategoryContract.GetAllCategoriesForCustomer` | `/categories`, `/products` | درخت دسته‌بندی محصولات |
| 6.5 | `CartService` — Add/Update/Remove/Get | `UserCartsContract.*ForCustomer` | `/`, `/products`, `/product/{id}`, `/cart` | مدیریت سبد خرید (AddNew, Update, Remove, GetCustomerCart) |
| 6.6 | `UserOrderContract.SubmitShopBuyOrder` | `UserOrderContract` | `/checkout-summary` | ثبت سفارش و پرداخت از کیف پول |
| 6.7 | `OrderService.GetOrdersAsync` | `UserOrderContract.GetCustomerOrders` | `/orders` | لیست سفارش‌های کاربر |
| 6.8 | `OrderService.GetOrderAsync` | `UserOrderContract.GetCustomerOrder` | `/order/{id}`, `/order-tracking/{id}` | جزئیات یک سفارش |
| 6.10 | `VATService.GetRateAsync` | `UserOrderContract` (via scope) | Store/Discount صفحات قیمت | نرخ مالیات بر ارزش افزوده (کش روزانه) |
| 6.11 | `WalletService.GetBalancesAsync` | `UserWalletContract` | `/checkout-summary` | بررسی موجودی برای پرداخت سفارش |
---
## ۷. فروشگاه تخفیفی (Discount Store)
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 7.1 | `DiscountProductService.GetProductsAsync` | `DiscountProductContract.GetDiscountProducts` | `/discount-store` | لیست محصولات تخفیفی |
| 7.2 | `DiscountProductService.GetTopSellingAsync` | `DiscountProductContract` | `/` | پرفروش‌های فروشگاه تخفیفی |
| 7.3 | `DiscountProductService.GetByIdAsync` | `DiscountProductContract.GetDiscountProductById` + Images | `/discount-store/product/{id}` | جزئیات محصول تخفیفی |
| 7.4 | `DiscountProductService.GetCategoriesAsync` | `DiscountCategoryContract.GetDiscountCategories` | `/discount-store` | دسته‌بندی‌های فروشگاه تخفیفی |
| 7.5 | `DiscountCartService` — Add/Update/Remove/Get | `DiscountShoppingCartContract.*` | `/discount-store`, `/discount-store/cart` | سبد خرید تخفیفی |
| 7.6 | `DiscountOrderService.PlaceOrderAsync` | `DiscountOrderContract.PlaceOrder` | `/discount-store/checkout` | ثبت سفارش (کیف تخفیفی + درگاه) |
| 7.7 | `DiscountOrderService.GetUserOrdersAsync` | `DiscountOrderContract.GetUserOrders` | `/discount-store/orders` | لیست سفارش‌های تخفیفی |
| 7.8 | `DiscountOrderService.GetOrderByIdAsync` | `DiscountOrderContract.GetOrderById` | `/discount-store/order/{id}` | جزئیات سفارش تخفیفی |
| 7.9 | `UserAddressContract.GetCustomerAddresses` | `UserAddressContract` | `/discount-store/checkout` | انتخاب آدرس تحویل |
---
## ۸. باشگاه مشتریان (Club)
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 8.1 | `ClubMembershipService.GetMyMembershipAsync` | `ClubMembershipContract.GetClubMembership` | `/club/membership` | وضعیت عضویت باشگاه |
| 8.2 | `ClubConfigurationService.GetClubConfigurationAsync` | `ConfigurationContract.GetClubConfiguration` | `/club/membership` | هزینه فعال‌سازی و هدیه عضویت |
| 8.3 | `ClubConfigurationService.GetClubFeaturesAsync` | `ConfigurationContract.GetClubFeatures` | `/club/features` | لیست امکانات باشگاه |
| 8.4 | `ClubMembershipService.ActivateMembershipAsync` | `ClubMembershipContract.ActivateClubMembership` | `Club/ActivationSection` | فعال‌سازی/تمدید عضویت باشگاه |
| 8.5 | `OtpTokenContract.CreateNewOtpToken` | `OtpTokenContract` | `ClubMembershipContractDialog` | OTP برای امضای قرارداد باشگاه |
| 8.6 | `ClubMembershipContract.AcceptClubMembershipContract` | `ClubMembershipContract` | `ClubMembershipContractDialog` | ثبت امضای قرارداد باشگاه |
---
## ۹. شبکه فروش (Network / MLM)
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 9.1 | `NetworkMembershipService.GetMyNetworkTreeAsync` | `NetworkMembershipContract.GetMyNetworkTree` | `/profile/tree` (`OrganizationChart`) | درخت سازمان فروش کاربر |
| 9.2 | `NetworkMembershipService.GetSubordinateTreeAsync` | `NetworkMembershipContract.GetSubordinateTree` | `/profile/tree` | drill-down به زیرمجموعه |
| 9.3 | `NetworkMembershipService.GetMyNetworkStatisticsAsync` | `NetworkMembershipContract.GetMyNetworkStatistics` | `/profile/tree`, `/network/statistics` | آمار پا چپ/راست، تعداد اعضا |
---
## ۱۰. کمیسیون و پاداش
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 10.1 | `CommissionService.GetWeekDefinitionsAsync` | `CommissionContract.GetWeekDefinitions` | `Shared/WeekSelector`, Commission pages | لیست هفته‌های محاسبه پاداش |
| 10.2 | `CommissionService.GetMyCommissionPayoutsAsync` | `CommissionContract.GetMyCommissionPayouts` | `/commission/dashboard` | تاریخچه پرداخت پاداش‌ها |
| 10.3 | `CommissionService.GetMyWeeklyBalanceAsync` | `CommissionContract.GetMyWeeklyBalances` | `/commission/weekly-balance` | جزئیات بالانس هفتگی (چپ/راست) |
| 10.4 | `PackageService.GetAllPackagesAsync` | `PackageContract` | Commission pages | فیلتر پاداش بر اساس پکیج |
---
## ۱۱. محتوای سایت، بلاگ و صفحات ثابت
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 11.1 | `SitePageSettingsService.GetPageAsync` | `SitePageSettingsContract.GetPageSettings` | `/` (landing), `/about`, `/contact`, `/licenses` | محتوای داینامیک صفحات (hero، تصاویر، JSON تنظیمات) |
| 11.2 | `BlogPostService.GetFeaturedPostsAsync` | `BlogPostContract.GetFeaturedBlogPosts` | `/`, `/profile` | پست‌های ویژه |
| 11.3 | `BlogPostService.GetPublishedPostsAsync` | `BlogPostContract.GetPublishedBlogPosts` | `/`, `/blog` | لیست پست‌های منتشرشده |
| 11.4 | `BlogPostService.GetBySlugAsync` | `BlogPostContract.GetBlogPostBySlug` | `/blog/{slug}` | محتوای کامل یک پست |
| 11.5 | `BlogPostService.IncrementViewCountAsync` | `BlogPostContract.IncrementViewCount` | `/blog/{slug}` | افزایش شمارنده بازدید |
| 11.6 | `BlogCategoryService.GetActiveCategoriesAsync` | `BlogCategoryContract` | `/blog` | فیلتر دسته‌بندی بلاگ |
| 11.7 | FAQ | — (hardcoded) | `/faq` | سوالات متداول — **بدون فراخوانی API** |
---
## ۱۲. زیرساخت مشترک (Infrastructure)
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 12.1 | `ImageCacheService.ResolveAsync` | `ImageResolverContract` | `Shared/AppImage` (سراسری) | resolve مسیر تصویر CMS به data-URI |
| 12.2 | `AppVersionService.CheckVersionAsync` | `AppVersionContract` | `App.razor`, `MainLayout` | بررسی نسخه جدید اپ |
| 12.3 | `AppVersionService.ApplyUpdateAsync` / `SkipVersionAsync` | — (localStorage + reload) | `App.razor`, `MainLayout` | اعمال یا رد آپدیت |
| 12.4 | `MainService.OnChangeHandler` | — (in-memory) | `/`, `AuthDialog` | اطلاع‌رسانی تغییر state سراسری (مثلاً پس از login) |
| 12.5 | `IChromiumPdfService` | — (local endpoint `/contract/generate`) | `/register` | تولید PDF قرارداد (Chromium headless) |
---
## ۱۳. Gateway / انتخاب‌گر
| صفحه | سرویس | کاربرد |
|------|--------|--------|
| `/stores` | — | انتخاب بین فروشگاه اصلی و تخفیفی |
| `/my-orders` | — | انتخاب لیست سفارش‌ها |
| `/my-cart` | — | انتخاب سبد خرید |
این صفحات فقط مسیریابی UI هستند و مستقیماً gRPC صدا نمی‌زنند.
---
## ۱۴. سرویس‌های ثبت‌شده ولی بدون استفاده در UI
| سرویس / Contract | وضعیت |
|------------------|--------|
| `SitePageService` (`SitePageContract`) | ثبت در DI؛ **هیچ صفحه‌ای inject نمی‌کند** (جایگزین: `SitePageSettingsService`) |
| `TransactionsContract` | ثبت در DI؛ **استفاده نشده** |
| `UserWalletHistoryContract` | ثبت در DI؛ **استفاده نشده** (تاریخچه از `UserWalletContract.GetCustomerWalletHistory` می‌آید) |
| `TokenNotificationService` | ثبت در DI؛ **هنوز به Layout وصل نشده** (SignalR برای invalidation توکن) |
---
## نمودار جریان پرداخت
```mermaid
flowchart LR
subgraph initiate [شروع پرداخت]
A1[Profile / Checkout]
A2[MagicWallet / ChargeDiscount]
A3[Discount Checkout]
end
subgraph gateway [Zarinpal]
G[درگاه پرداخت]
end
subgraph callback [PaymentCallback]
C1[Verify Package]
C2[Verify Magic]
C3[Verify Discount Wallet]
C4[Verify Discount Order]
end
A1 --> G
A2 --> G
A3 --> G
G --> callback
```
---
## فهرست gRPC Contractهای فعال
| Contract | Wrapper اصلی |
|----------|----------------|
| `UserContract` | `AuthService`, صفحات Profile/Register |
| `UserAddressContract` | Profile, Checkout pages |
| `CityContract` | Address dialogs |
| `PackageContract` | `PackageService`, PaymentCallback, Checkout |
| `UserWalletContract` | `WalletService` |
| `UserOrderContract` | `OrderService`, CheckoutSummary |
| `UserCartsContract` | `CartService` |
| `ProductsContract` | `ProductService` |
| `CategoryContract` | `CategoryService` |
| `DiscountProductContract` / `DiscountCategoryContract` | `DiscountProductService` |
| `DiscountShoppingCartContract` | `DiscountCartService` |
| `DiscountOrderContract` | `DiscountOrderService` |
| `ClubMembershipContract` | `ClubMembershipService`, Dialog |
| `OtpTokenContract` | Club contract dialog |
| `ConfigurationContract` | `ClubConfigurationService` |
| `NetworkMembershipContract` | `NetworkMembershipService` |
| `CommissionContract` | `CommissionService` |
| `BlogPostContract` | `BlogPostService` |
| `BlogCategoryContract` | `BlogCategoryService` |
| `SitePageSettingsContract` | `SitePageSettingsService` |
| `AppVersionContract` | `AppVersionService` |
| `ImageResolverContract` | `ImageCacheService` |
+1 -1
View File
@@ -20,7 +20,7 @@ spec:
spec:
containers:
- name: frontoffice
image: 194.5.195.53:30080/admin/frontoffice:prod
image: 185.252.31.44:30080/admin/frontoffice:prod
imagePullPolicy: Always
ports:
- containerPort: 80
+32 -3
View File
@@ -4,7 +4,7 @@ apiVersion: apps/v1
kind: Deployment
metadata:
name: frontoffice
namespace: default
namespace: kbs-stage
labels:
app: frontoffice
environment: staging
@@ -20,7 +20,8 @@ spec:
spec:
containers:
- name: frontoffice
image: 194.5.195.53:30080/admin/frontoffice:latest
image: 185.252.31.44:30080/admin/frontoffice:latest
# Staging uses :latest — Always avoids stale node cache after registry retag
imagePullPolicy: Always
ports:
- containerPort: 80
@@ -43,7 +44,7 @@ apiVersion: v1
kind: Service
metadata:
name: frontoffice-svc
namespace: default
namespace: kbs-stage
labels:
app: frontoffice
spec:
@@ -54,3 +55,31 @@ spec:
targetPort: 80
name: http
type: ClusterIP
---
# FrontOffice Ingress
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: frontoffice-ingress
namespace: kbs-stage
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
ingressClassName: nginx
tls:
- hosts:
- frontoffice.se.kbs1.ir
secretName: frontoffice-tls
rules:
- host: frontoffice.se.kbs1.ir
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: frontoffice-svc
port:
number: 80
@@ -30,6 +30,7 @@ using CMSMicroservice.Protobuf.Protos.DiscountCategory;
using CMSMicroservice.Protobuf.Protos.DiscountShoppingCart;
using CMSMicroservice.Protobuf.Protos.DiscountOrder;
using FrontOffice.Main.Utilities;
using FrontOffice.Main.Utilities.Seo;
namespace Microsoft.Extensions.DependencyInjection;
@@ -95,6 +96,10 @@ public static class ConfigureServices
// SignalR Token Notification Service
services.AddScoped<TokenNotificationService>();
// SEO
services.AddScoped<SeoMetadataProvider>();
services.AddScoped<SitemapGenerator>();
return services;
}
+2 -2
View File
@@ -1,4 +1,4 @@
FROM 194.5.195.53:32082/dotnet/sdk:9.0 AS build
FROM 185.252.31.44:32082/dotnet/sdk:9.0 AS build
WORKDIR /src
# Copy NuGet config and project file
@@ -16,7 +16,7 @@ WORKDIR "/src/FrontOffice.Main"
RUN dotnet publish "FrontOffice.Main.csproj" -c Release -o /app/publish
# Runtime stage - aspnet for Blazor Server
FROM 194.5.195.53:32082/dotnet/aspnet:9.0 AS runtime
FROM 185.252.31.44:32082/dotnet/aspnet:9.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .
+15 -4
View File
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
@@ -10,9 +10,7 @@
<ItemGroup>
<PackageReference Include="DateTimeConverterCL" Version="1.0.0" />
<!-- Replace all FrontOffice.BFF protobuf packages with CMS protobuf -->
<PackageReference Include="Foursat.CMSMicroservice.Protobuf" Version="0.0.197" />
<!-- <ProjectReference Include="../../../CMS/src/CMSMicroservice.Protobuf/CMSMicroservice.Protobuf.csproj" />-->
<PackageReference Include="MudBlazor" Version="8.14.0" />
<PackageReference Include="Blazored.LocalStorage" Version="4.5.0" />
<PackageReference Include="Mapster" Version="7.4.0" />
@@ -24,6 +22,19 @@
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.0" />
</ItemGroup>
<!-- ============================================================ -->
<!-- Proto References: Direct reference to CMS Protobuf project -->
<!-- This enables direct communication with CMS (no BFF needed) -->
<!-- ============================================================ -->
<!-- CMS Protobuf: NuGet in CI/Docker; ProjectReference for local monorepo dev -->
<ItemGroup Condition="Exists('../../../CMS/src/CMSMicroservice.Protobuf/CMSMicroservice.Protobuf.csproj')">
<ProjectReference Include="../../../CMS/src/CMSMicroservice.Protobuf/CMSMicroservice.Protobuf.csproj" />
</ItemGroup>
<ItemGroup Condition="!Exists('../../../CMS/src/CMSMicroservice.Protobuf/CMSMicroservice.Protobuf.csproj')">
<PackageReference Include="Foursat.CMSMicroservice.Protobuf" Version="0.0.213" />
</ItemGroup>
<!-- Local Proto Project Reference for development -->
<!-- <ItemGroup>-->
<!-- <ProjectReference Include="..\..\..\FrontOffice.BFF\src\Protobufs\FrontOffice.BFF.Package.Protobuf\FrontOffice.BFF.Package.Protobuf.csproj" />-->
+1 -1
View File
@@ -3,7 +3,7 @@
<packageSources>
<clear />
<!-- Nexus (hosts FourSat packages + proxies nuget.org) -->
<add key="Nexus" value="http://194.5.195.53:32081/repository/nuget-all/index.json" allowInsecureConnections="true" />
<add key="Nexus" value="http://185.252.31.44:32081/repository/nuget-all/index.json" allowInsecureConnections="true" />
</packageSources>
<packageSourceCredentials>
<Nexus>
+13 -76
View File
@@ -71,80 +71,6 @@
</MudStack>
}
</MudPaper>
<!-- Address Selection -->
<MudPaper Elevation="2" Class="pa-4 rounded-lg">
<MudText Typo="Typo.h5" Class="mb-4">انتخاب آدرس</MudText>
@if (_isLoadingAddresses)
{
<MudStack AlignItems="AlignItems.Center" Class="py-4">
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Medium" />
<MudText Typo="Typo.body2" Class="mud-text-secondary mt-2">بارگذاری آدرس‌ها...</MudText>
</MudStack>
}
else if (_addresses.Any())
{
<MudStack Spacing="2">
@foreach (var address in _addresses)
{
<MudPaper Outlined="@(_selectedAddress?.Id != address.Id)"
Elevation="@(_selectedAddress?.Id == address.Id ? 4 : 0)"
Class="pa-3 rounded-xl cursor-pointer"
Style="@(_selectedAddress?.Id == address.Id ? "border: 2px solid var(--mud-palette-primary);" : "")"
@onclick="() => SetAddressAsDefault(address.Id)">
<MudStack Spacing="1">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudCheckBox @bind-Value="address.IsDefault"
Disabled="true"
Color="Color.Primary" />
<MudText Typo="Typo.subtitle2">@(address.Title)</MudText>
@if (address.IsDefault)
{
<MudChip T="string" Color="Color.Success" Variant="Variant.Filled" Size="Size.Small">پیش‌فرض</MudChip>
}
@if (!address.IsDefault)
{
@if (_isSettingDefaultAddress && _settingDefaultAddressId == address.Id)
{
<MudProgressCircular Size="Size.Small" Color="Color.Primary" Indeterminate="true" />
}
else
{
<MudButton Variant="Variant.Text"
Color="Color.Primary"
Size="Size.Small"
OnClick="() => SetAddressAsDefault(address.Id)">
تنظیم به عنوان پیش‌فرض
</MudButton>
}
}
</MudStack>
<MudStack Row="true">
<MudText Typo="Typo.body2" Class="mud-text-secondary">@(address.Address)</MudText>
<MudSpacer />
<MudText Typo="Typo.caption" Class="mud-text-secondary">کد پستی: @(address.PostalCode)</MudText>
</MudStack>
</MudStack>
</MudPaper>
}
</MudStack>
}
else
{
<MudStack AlignItems="AlignItems.Center" Class="py-4">
<MudIcon Icon="@Icons.Material.Filled.LocationOff" Size="Size.Large" Color="Color.Default" />
<MudText Typo="Typo.body2" Class="mud-text-secondary mt-2">آدرسی ثبت نشده است.</MudText>
<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="() => Navigation.NavigateTo(RouteConstants.Profile.Index)"
Class="mt-2">
افزودن آدرس
</MudButton>
</MudStack>
}
</MudPaper>
</MudStack>
</MudItem>
@@ -214,7 +140,18 @@
OnClick="ProcessPayment"
Disabled="@(!CanProceedToPayment || _isProcessingPayment || !_selectedPackage.SupportsDirectPurchase)"
Class="mt-2">
@(_isProcessingPayment ? "در حال پردازش..." : "پرداخت آنلاین")
@(_isProcessingPayment ? "در حال پردازش..." : "پرداخت آنلاین (زرین‌پال)")
</MudButton>
<MudButton Variant="Variant.Filled"
Color="Color.Info"
Size="Size.Large"
FullWidth="true"
StartIcon="@Icons.Material.Filled.AccountBalance"
OnClick="ProcessTwentyCardPayment"
Disabled="@(!CanProceedToPayment || _isProcessingPayment || !_selectedPackage.SupportsDirectPurchase)"
Class="mt-2">
پرداخت با ۲۰کارت
</MudButton>
@if (_selectedPackage.SupportsDayaPurchase)
@@ -241,7 +178,7 @@
@if (!CanProceedToPayment)
{
<MudText Typo="Typo.caption" Color="Color.Error" Align="Align.Center">
لطفاً پکیج و آدرس را انتخاب کنید.
لطفاً پکیج را انتخاب کنید.
</MudText>
}
</MudStack>
+39 -73
View File
@@ -1,6 +1,4 @@
using CMSMicroservice.Protobuf.Protos.Package;
using CMSMicroservice.Protobuf.Protos.UserAddress;
using FrontOffice.Main.Utilities;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
@@ -13,14 +11,10 @@ public partial class Checkout
{
[Inject] private PackageService PackageService { get; set; } = default!;
[Inject] private PackageContract.PackageContractClient PackageClient { get; set; } = default!;
[Inject] private UserAddressContract.UserAddressContractClient UserAddressContract { get; set; } = default!;
[Parameter] public long? PackageId { get; set; }
private Pack? _selectedPackage;
private List<CustomerAddressModel> _addresses = new();
private CustomerAddressModel? _selectedAddress;
private bool _isLoadingAddresses;
private bool _isProcessingPayment;
// Discount code
@@ -31,16 +25,11 @@ public partial class Checkout
private long _discountAmount;
private long _finalPrice;
// Address management
private bool _isSettingDefaultAddress;
private long? _settingDefaultAddressId;
private bool CanProceedToPayment => _selectedPackage != null && _selectedAddress != null;
private bool CanProceedToPayment => _selectedPackage != null;
protected override async Task OnInitializedAsync()
{
await LoadPackageDetails();
await LoadAddresses();
}
private async Task LoadPackageDetails()
@@ -72,60 +61,6 @@ public partial class Checkout
}
}
private async Task LoadAddresses()
{
_isLoadingAddresses = true;
try
{
var response = await UserAddressContract.GetCustomerAddressesAsync(request: new());
if (response?.Models?.Any() == true)
{
_addresses = response.Models.ToList();
// Select default address if available
_selectedAddress = _addresses.FirstOrDefault(a => a.IsDefault) ?? _addresses.First();
}
else
{
_addresses = new List<CustomerAddressModel>();
}
}
catch (Exception ex)
{
Snackbar.Add($"خطا در بارگذاری آدرس‌ها: {ex.Message}", Severity.Error);
_addresses = new List<CustomerAddressModel>();
}
finally
{
_isLoadingAddresses = false;
await InvokeAsync(StateHasChanged);
}
}
private async Task SetAddressAsDefault(long addressId)
{
if (_isSettingDefaultAddress) return;
_isSettingDefaultAddress = true;
_settingDefaultAddressId = addressId;
try
{
await UserAddressContract.SetAddressAsDefaultAsync(new() { Id = addressId });
await LoadAddresses(); // Reload addresses to reflect the change
Snackbar.Add("آدرس پیش‌فرض با موفقیت تغییر یافت.", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"خطا در تغییر آدرس پیش‌فرض: {ex.Message}", Severity.Error);
}
finally
{
_isSettingDefaultAddress = false;
_settingDefaultAddressId = null;
await InvokeAsync(StateHasChanged);
}
}
private async Task ApplyDiscountCode()
{
if (string.IsNullOrWhiteSpace(_discountCode))
@@ -157,7 +92,7 @@ public partial class Checkout
_finalPrice = _selectedPackage!.Price;
}
}
catch (Exception ex)
catch (Exception)
{
_discountMessage = "خطا در اعمال کد تخفیف.";
_discountApplied = false;
@@ -172,12 +107,39 @@ public partial class Checkout
}
private async Task ProcessPayment()
=> await StartGatewayPayment(gatewayProvider: null);
private async Task ProcessTwentyCardPayment()
{
if (!CanProceedToPayment || _selectedPackage == null)
{
Snackbar.Add("لطفاً پکیج را انتخاب کنید.", Severity.Warning);
return;
}
var options = new DialogOptions
{
MaxWidth = MaxWidth.Small,
FullWidth = true,
CloseOnEscapeKey = true,
BackdropClick = true
};
var dialog = await DialogService.ShowAsync<Shared.TwentyCardCreditDialog>(
string.Empty, options);
var result = await dialog.Result;
if (result.Canceled) return;
await StartGatewayPayment(gatewayProvider: "20card");
}
private async Task StartGatewayPayment(string? gatewayProvider)
{
if (_isProcessingPayment) return;
if (!CanProceedToPayment || _selectedPackage == null || _selectedAddress == null)
if (!CanProceedToPayment || _selectedPackage == null)
{
Snackbar.Add("لطفاً پکیج و آدرس را انتخاب کنید.", Severity.Warning);
Snackbar.Add("لطفاً پکیج را انتخاب کنید.", Severity.Warning);
return;
}
@@ -185,11 +147,15 @@ public partial class Checkout
try
{
var response = await PackageClient.CustomerPurchasePackageAsync(new CustomerPurchasePackageRequest
var request = new CustomerPurchasePackageRequest
{
PackageId = _selectedPackage.Id,
PurchaseMethod = PurchaseMethodEnum.PurchaseMethodGateway
});
};
if (!string.IsNullOrWhiteSpace(gatewayProvider))
request.GatewayProvider = gatewayProvider;
var response = await PackageClient.CustomerPurchasePackageAsync(request);
if (!response.Success)
{
@@ -219,9 +185,9 @@ public partial class Checkout
private async Task DayaLoanPayment()
{
if (_selectedAddress == null)
if (_selectedPackage == null)
{
Snackbar.Add("لطفاً آدرس را انتخاب کنید.", Severity.Warning);
Snackbar.Add("لطفاً پکیج را انتخاب کنید.", Severity.Warning);
return;
}
@@ -22,9 +22,11 @@
else if (_addresses.Count == 0)
{
<MudAlert Severity="Severity.Warning">
هیچ آدرسی ثبت نشده است. لطفاً از بخش پروفایل آدرس خود را اضافه کنید.
هیچ آدرسی ثبت نشده است. می‌توانید همین‌جا آدرس جدید اضافه کنید.
</MudAlert>
<MudButton Class="mt-2" Variant="Variant.Outlined" Href="/profile/addresses">افزودن آدرس</MudButton>
<MudButton Class="mt-2" Variant="Variant.Outlined" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="OpenAddAddressDialog">افزودن آدرس</MudButton>
}
else
{
@@ -41,13 +43,25 @@
<MudText Typo="Typo.subtitle2">@address.Title</MudText>
<MudText Typo="Typo.body2" Class="mud-text-secondary">@address.Address</MudText>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
@if (address.IsDefault)
{
<MudChip T="string" Color="Color.Success" Variant="Variant.Outlined">پیش‌فرض</MudChip>
<MudChip T="string" Color="Color.Success" Variant="Variant.Outlined" Size="Size.Small">پیش‌فرض</MudChip>
}
<span @onclick:stopPropagation="true">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Size="Size.Small"
Color="Color.Primary"
aria-label="ویرایش آدرس"
OnClick="@(() => OpenEditAddressDialog(address))" />
</span>
</MudStack>
</MudStack>
</MudPaper>
}
<MudButton Class="mt-2" Variant="Variant.Text" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="OpenAddAddressDialog">افزودن آدرس جدید</MudButton>
</MudStack>
}
</MudPaper>
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Components;
using CMSMicroservice.Protobuf.Protos.UserAddress;
using FrontOffice.Main.Pages.Profile.Components;
using FrontOffice.Main.Utilities;
using MudBlazor;
@@ -40,7 +41,11 @@ public partial class Checkout
}
await VAT.LoadAsync();
await DiscountCart.EnsureInitializedAsync();
var userInfo = await AuthService.GetUserAuthInfo();
if (userInfo.HasAddress)
await LoadAddresses();
else
_addresses = new();
}
private async Task LoadAddresses()
@@ -57,12 +62,14 @@ public partial class Checkout
else
{
_addresses = new();
_selectedAddress = null;
}
}
catch (Exception ex)
{
Snackbar.Add($"خطا در بارگذاری آدرس‌ها: {ex.Message}", Severity.Error);
_addresses = new();
_selectedAddress = null;
}
finally
{
@@ -71,6 +78,30 @@ public partial class Checkout
}
}
private async Task OpenAddAddressDialog()
{
var dialog = await DialogService.ShowAsync<AddAddressDialog>("افزودن آدرس جدید");
var result = await dialog.Result;
if (result is { Canceled: false })
{
await AuthService.RefreshTokenAsync();
await LoadAddresses();
}
}
private async Task OpenEditAddressDialog(CustomerAddressModel address)
{
var dialog = await DialogService.ShowAsync<EditAddressDialog>("ویرایش آدرس", new DialogParameters<EditAddressDialog>
{
{ x => x.Model, address }
});
var result = await dialog.Result;
if (result is { Canceled: false })
{
await LoadAddresses();
}
}
private async Task PlaceOrder()
{
if (!CanPlaceOrder || _selectedAddress is null)
@@ -76,6 +76,7 @@
{
<MudItem xs="6" sm="6" md="3"
onclick="@(() => NavigateToProduct(p.Id))">
<div id="@($"shop-product-{p.Id}")" class="h-100">
<MudCard Class="rounded-lg h-100 d-flex flex-column overflow-hidden"
Style="cursor:pointer;">
<MudCardContent Class="d-flex flex-column pa-1 h-100">
@@ -127,6 +128,7 @@
</MudButton>
</MudCardActions>
</MudCard>
</div>
</MudItem>
}
</MudGrid>
@@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.JSInterop;
using FrontOffice.Main.Utilities;
namespace FrontOffice.Main.Pages.DiscountStore;
@@ -10,6 +12,7 @@ public partial class Products : ComponentBase, IDisposable
[Inject] private DiscountCartService DiscountCart { get; set; } = default!;
[Inject] private GuestActionGate GuestGate { get; set; } = default!;
[Inject] private AuthService AuthService { get; set; } = default!;
[Inject] private IJSRuntime Js { get; set; } = default!;
private bool _isAuthenticated;
private string _search = string.Empty;
@@ -20,42 +23,107 @@ public partial class Products : ComponentBase, IDisposable
private bool _hasMore = true;
private int _totalCount;
private const int PageSize = 12;
private const string DefaultSortBy = "price desc";
private List<DiscountProductCard> _products = new();
private List<DiscountCategoryNode> _categories = new();
private bool _ignoreNextLocationChange;
private bool _pendingScrollRestore;
protected override async Task OnInitializedAsync()
{
_isAuthenticated = await AuthService.IsAuthenticatedAsync();
_loading = true;
await DiscountCart.EnsureInitializedAsync();
DiscountCart.OnChange += StateHasChanged;
var categoriesTask = ProductService.GetCategoriesAsync();
var productsTask = ProductService.GetProductsAsync(page: 1, pageSize: PageSize);
await Task.WhenAll(categoriesTask, productsTask);
_categories = categoriesTask.Result;
var result = productsTask.Result;
_products = result.Products;
_totalCount = result.TotalCount;
_hasMore = result.CurrentPage < result.TotalPages;
Navigation.LocationChanged += HandleLocationChanged;
_categories = await ProductService.GetCategoriesAsync();
ApplyStateFromUri();
_loading = true;
await LoadPages(_currentPage);
_loading = false;
_pendingScrollRestore = true;
}
private async Task LoadInitial()
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (_pendingScrollRestore && !_loading && _products.Count > 0)
{
_pendingScrollRestore = false;
var payload = await ShopListScrollRestore.TakeAsync(Js, ShopListScrollRestore.DiscountKey);
if (payload is not null)
await ShopListScrollRestore.RestoreAsync(Js, payload);
}
await base.OnAfterRenderAsync(firstRender);
}
private void ApplyStateFromUri()
{
var state = ShopListQueryState.Parse(Navigation.ToAbsoluteUri(Navigation.Uri));
_search = state.Query;
_selectedCategoryId = state.CategoryId;
_currentPage = state.Pages;
}
private ShopListQueryState CaptureState() => new()
{
Query = _search,
CategoryId = _selectedCategoryId,
Pages = Math.Max(1, _currentPage)
};
private void SyncUrl()
{
var target = CaptureState().ToRelativeUrl(RouteConstants.DiscountStore.Products);
var currentPathAndQuery = Navigation.ToAbsoluteUri(Navigation.Uri).PathAndQuery;
if (string.Equals(currentPathAndQuery, target, StringComparison.OrdinalIgnoreCase))
return;
_ignoreNextLocationChange = true;
Navigation.NavigateTo(target, replace: true);
}
private async Task LoadPages(int pagesToLoad)
{
_products.Clear();
_hasMore = true;
pagesToLoad = Math.Max(1, pagesToLoad);
var search = string.IsNullOrWhiteSpace(_search) ? null : _search;
for (var page = 1; page <= pagesToLoad; page++)
{
var result = await ProductService.GetProductsAsync(
page: page,
pageSize: PageSize,
search: search,
categoryId: _selectedCategoryId,
sortBy: DefaultSortBy);
if (page == 1)
{
_products = result.Products;
_totalCount = result.TotalCount;
}
else
{
_products.AddRange(result.Products);
}
_currentPage = page;
_hasMore = result.CurrentPage < result.TotalPages;
if (!_hasMore)
break;
}
}
private async Task ReloadFromFilters()
{
_loading = true;
_currentPage = 1;
_products.Clear();
StateHasChanged();
var result = await ProductService.GetProductsAsync(
page: 1,
pageSize: PageSize,
search: string.IsNullOrWhiteSpace(_search) ? null : _search,
categoryId: _selectedCategoryId);
_products = result.Products;
_totalCount = result.TotalCount;
_hasMore = result.CurrentPage < result.TotalPages;
await LoadPages(1);
SyncUrl();
_loading = false;
StateHasChanged();
}
@@ -72,28 +140,28 @@ public partial class Products : ComponentBase, IDisposable
page: _currentPage,
pageSize: PageSize,
search: string.IsNullOrWhiteSpace(_search) ? null : _search,
categoryId: _selectedCategoryId);
categoryId: _selectedCategoryId,
sortBy: DefaultSortBy);
_products.AddRange(result.Products);
_hasMore = result.CurrentPage < result.TotalPages;
SyncUrl();
_loadingMore = false;
StateHasChanged();
}
private async Task SearchProducts() => await LoadInitial();
private async Task SearchProducts() => await ReloadFromFilters();
private async Task OnSearchKeyUp(KeyboardEventArgs e)
{
if (e.Key == "Enter")
{
await LoadInitial();
}
await ReloadFromFilters();
}
private async Task OnCategoryChanged(long? value)
{
_selectedCategoryId = value;
await LoadInitial();
await ReloadFromFilters();
}
private async Task AddToCart(DiscountProductCard p)
@@ -101,15 +169,44 @@ public partial class Products : ComponentBase, IDisposable
await GuestGate.RunAsync(() => DiscountCart.AddAsync(p.Id));
}
private void NavigateToProduct(long id)
private async Task NavigateToProduct(long id)
{
await ShopListScrollRestore.SaveAsync(Js, ShopListScrollRestore.DiscountKey, id);
Navigation.NavigateTo($"{RouteConstants.DiscountStore.ProductDetail}{id}");
}
private void HandleLocationChanged(object? sender, LocationChangedEventArgs args)
{
if (_ignoreNextLocationChange)
{
_ignoreNextLocationChange = false;
return;
}
var uri = Navigation.ToAbsoluteUri(args.Location);
if (!uri.AbsolutePath.Equals(RouteConstants.DiscountStore.Products, StringComparison.OrdinalIgnoreCase))
return;
var incoming = ShopListQueryState.Parse(uri);
if (incoming.Matches(CaptureState()))
return;
_ = InvokeAsync(async () =>
{
ApplyStateFromUri();
_loading = true;
await LoadPages(_currentPage);
_loading = false;
_pendingScrollRestore = true;
StateHasChanged();
});
}
private static string FormatPrice(long price) => $"{price:N0} تومان";
public void Dispose()
{
DiscountCart.OnChange -= StateHasChanged;
Navigation.LocationChanged -= HandleLocationChanged;
}
}
+126 -79
View File
@@ -93,6 +93,129 @@
</section>
}
@* ═══════════════════════════════════════════════
1c. TWENTYCARD CREDIT — promo box
═══════════════════════════════════════════════ *@
<section class="py-4" style="background:var(--mud-palette-surface);">
<MudContainer MaxWidth="MaxWidth.Medium">
<MudPaper Elevation="0" Class="landing-twentycard-box rounded-xl pa-4 pa-md-5">
<MudStack Row="true" Spacing="3" AlignItems="AlignItems.Start" Class="flex-wrap">
<MudAvatar Color="Color.Info" Variant="Variant.Filled" Size="Size.Large" Class="rounded-lg" Style="min-width:56px;">
<MudIcon Icon="@Icons.Material.Filled.AccountBalance" />
</MudAvatar>
<MudStack Spacing="2" Style="flex:1; min-width:220px;">
<MudText Typo="Typo.h6">تأمین اعتبار ۲۰کارت</MudText>
<MudText Typo="Typo.body2" Class="mud-text-secondary">
با تأمین اعتبار از طریق ۲۰کارت می‌توانید بدون پرداخت نقدی کامل، هزینه خرید پکیج و محصولات در کارا بازار را پوشش دهید.
فرایند درخواست و تأیید اعتبار در پنل ۲۰کارت انجام می‌شود و پس از آن پرداخت از همان مسیر در دسترس است.
این روش برای کسانی مناسب است که می‌خواهند سریع‌تر وارد مسیر عضویت و خرید شوند.
</MudText>
<MudText Typo="Typo.body2">
بعد از دریافت وام، می‌توانید در کارا بازار از باشگاه مشتریان استفاده کنید و با فروش و دعوت، توسعه سرمایه داشته باشید.
</MudText>
<div class="mt-1">
<MudButton Variant="Variant.Filled"
Color="Color.Info"
Class="rounded-pill"
StartIcon="@Icons.Material.Filled.OpenInNew"
Disabled="@string.IsNullOrWhiteSpace(_twentyCardPortalUrl)"
OnClick="OpenTwentyCardPortal">
ورود به سایت ۲۰کارت
</MudButton>
</div>
</MudStack>
</MudStack>
</MudPaper>
</MudContainer>
</section>
@* ═══════════════════════════════════════════════
2. HOW IT WORKS — 3 numbered steps
(appsettings Landing:ShowStepsSection; default false)
═══════════════════════════════════════════════ *@
@if (_showStepsSection)
{
<section class="section-landing">
<MudContainer MaxWidth="MaxWidth.Large">
<div class="text-center mb-8 fade-in-up">
<MudText Typo="Typo.h3">@(_settings?.StepsTitle ?? "سه گام تا شروع")</MudText>
<MudText Typo="Typo.body1" Class="mud-text-secondary mt-2">
بدون پیچیدگی، سریع و آسان
</MudText>
</div>
<MudGrid Spacing="4" Justify="Justify.Center">
@foreach (var (step, i) in _steps.Select((s, i) => (s, i)))
{
<MudItem xs="12" sm="4">
<MudPaper Elevation="0" Class="pa-5 rounded-xl feature-card-v2 text-center fade-in-up" data-delay="@(i * 150)">
<MudAvatar Size="Size.Large" Color="Color.Primary" Variant="Variant.Filled" Class="mx-auto mb-3 landing-step-num">
<MudText Typo="Typo.h5" Style="color:#fff;font-weight:800;">@(i + 1)</MudText>
</MudAvatar>
<MudText Typo="Typo.h6" Class="mb-1">@step.Title</MudText>
<MudText Typo="Typo.body2" Class="mud-text-secondary">@step.Desc</MudText>
</MudPaper>
</MudItem>
}
</MudGrid>
</MudContainer>
</section>
}
@* ═══════════════════════════════════════════════
3. FEATURES — 6 cards with icons
(appsettings Landing:ShowFeaturesSection; default false)
═══════════════════════════════════════════════ *@
@if (_showFeaturesSection)
{
<section class="section-landing" style="background:var(--mud-palette-background-gray);">
<MudContainer MaxWidth="MaxWidth.Large">
<div class="text-center mb-8 fade-in-up">
<MudText Typo="Typo.h3">@(_settings?.FeaturesTitle ?? "چرا کارا بازار سلامت؟")</MudText>
<MudText Typo="Typo.body1" Class="mud-text-secondary mt-2" Style="max-width:540px;margin:0 auto;">
ابزارهایی ساده و قدرتمند برای رشد کسب‌وکار شما
</MudText>
</div>
<MudGrid Spacing="3" Justify="Justify.Center">
@foreach (var (icon, title, desc, delay) in _features)
{
<MudItem xs="12" sm="6" md="4">
<MudPaper Elevation="0" Class="pa-5 rounded-xl feature-card-v2 fade-in-up" data-delay="@delay">
<MudStack Spacing="2">
<MudAvatar Color="Color.Primary" Variant="Variant.Filled" Size="Size.Medium" Class="mb-1">
<MudIcon Icon="@icon" />
</MudAvatar>
<MudText Typo="Typo.h6">@title</MudText>
<MudText Typo="Typo.body2" Class="mud-text-secondary">@desc</MudText>
</MudStack>
</MudPaper>
</MudItem>
}
</MudGrid>
</MudContainer>
</section>
}
@* ═══════════════════════════════════════════════
4. STATS — Counter cards
═══════════════════════════════════════════════ *@
<section class="section-landing">
<MudContainer MaxWidth="MaxWidth.Medium">
<MudGrid Spacing="3" Justify="Justify.Center">
@foreach (var stat in _stats)
{
<MudItem xs="6" sm="3">
<MudPaper Elevation="2" Class="pa-4 rounded-xl stat-card fade-in-up text-center">
<MudText Class="stat-number" Color="@stat.Color" id="@stat.ElementId">@stat.Display</MudText>
<MudText Typo="Typo.caption" Class="mud-text-secondary mt-1">@stat.Label</MudText>
</MudPaper>
</MudItem>
}
</MudGrid>
</MudContainer>
</section>
@* ═══════════════════════════════════════════════
1c. TOP-SELLING REGULAR PRODUCTS
═══════════════════════════════════════════════ *@
@@ -266,85 +389,6 @@ else
}
}
@* ═══════════════════════════════════════════════
2. HOW IT WORKS — 3 numbered steps
═══════════════════════════════════════════════ *@
<section class="section-landing">
<MudContainer MaxWidth="MaxWidth.Large">
<div class="text-center mb-8 fade-in-up">
<MudText Typo="Typo.h3">@(_settings?.StepsTitle ?? "سه گام تا شروع")</MudText>
<MudText Typo="Typo.body1" Class="mud-text-secondary mt-2">
بدون پیچیدگی، سریع و آسان
</MudText>
</div>
<MudGrid Spacing="4" Justify="Justify.Center">
@foreach (var (step, i) in _steps.Select((s, i) => (s, i)))
{
<MudItem xs="12" sm="4">
<MudPaper Elevation="0" Class="pa-5 rounded-xl feature-card-v2 text-center fade-in-up" data-delay="@(i * 150)">
<MudAvatar Size="Size.Large" Color="Color.Primary" Variant="Variant.Filled" Class="mx-auto mb-3 landing-step-num">
<MudText Typo="Typo.h5" Style="color:#fff;font-weight:800;">@(i + 1)</MudText>
</MudAvatar>
<MudText Typo="Typo.h6" Class="mb-1">@step.Title</MudText>
<MudText Typo="Typo.body2" Class="mud-text-secondary">@step.Desc</MudText>
</MudPaper>
</MudItem>
}
</MudGrid>
</MudContainer>
</section>
@* ═══════════════════════════════════════════════
3. FEATURES — 6 cards with icons
═══════════════════════════════════════════════ *@
<section class="section-landing" style="background:var(--mud-palette-background-gray);">
<MudContainer MaxWidth="MaxWidth.Large">
<div class="text-center mb-8 fade-in-up">
<MudText Typo="Typo.h3">@(_settings?.FeaturesTitle ?? "چرا کارا بازار سلامت؟")</MudText>
<MudText Typo="Typo.body1" Class="mud-text-secondary mt-2" Style="max-width:540px;margin:0 auto;">
ابزارهایی ساده و قدرتمند برای رشد کسب‌وکار شما
</MudText>
</div>
<MudGrid Spacing="3" Justify="Justify.Center">
@foreach (var (icon, title, desc, delay) in _features)
{
<MudItem xs="12" sm="6" md="4">
<MudPaper Elevation="0" Class="pa-5 rounded-xl feature-card-v2 fade-in-up" data-delay="@delay">
<MudStack Spacing="2">
<MudAvatar Color="Color.Primary" Variant="Variant.Filled" Size="Size.Medium" Class="mb-1">
<MudIcon Icon="@icon" />
</MudAvatar>
<MudText Typo="Typo.h6">@title</MudText>
<MudText Typo="Typo.body2" Class="mud-text-secondary">@desc</MudText>
</MudStack>
</MudPaper>
</MudItem>
}
</MudGrid>
</MudContainer>
</section>
@* ═══════════════════════════════════════════════
4. STATS — Counter cards
═══════════════════════════════════════════════ *@
<section class="section-landing">
<MudContainer MaxWidth="MaxWidth.Medium">
<MudGrid Spacing="3" Justify="Justify.Center">
@foreach (var stat in _stats)
{
<MudItem xs="6" sm="3">
<MudPaper Elevation="2" Class="pa-4 rounded-xl stat-card fade-in-up text-center">
<MudText Class="stat-number" Color="@stat.Color" id="@stat.ElementId">@stat.Display</MudText>
<MudText Typo="Typo.caption" Class="mud-text-secondary mt-1">@stat.Label</MudText>
</MudPaper>
</MudItem>
}
</MudGrid>
</MudContainer>
</section>
@* ═══════════════════════════════════════════════
5. LATEST BLOG POSTS
═══════════════════════════════════════════════ *@
@@ -429,6 +473,8 @@ else
@* ═══════════════════════════════════════════════
6. TESTIMONIALS
═══════════════════════════════════════════════ *@
@if (_testimonials.Count > 0)
{
<section class="section-landing">
<MudContainer MaxWidth="MaxWidth.Large">
<div class="text-center mb-8 fade-in-up">
@@ -465,6 +511,7 @@ else
</MudGrid>
</MudContainer>
</section>
}
@* ═══════════════════════════════════════════════
7. FAQ
+26 -6
View File
@@ -1,5 +1,6 @@
using FrontOffice.Main.Utilities;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Configuration;
using Microsoft.JSInterop;
using MudBlazor;
@@ -16,14 +17,20 @@ public partial class Index : IDisposable
[Inject] private DiscountCartService DiscountCart { get; set; } = default!;
[Inject] private GuestActionGate GuestGate { get; set; } = default!;
[Inject] private VATService VAT { get; set; } = default!;
[Inject] private IConfiguration Configuration { get; set; } = default!;
private bool _isAuthenticated;
private string _twentyCardPortalUrl = string.Empty;
private bool _showStepsSection;
private bool _showFeaturesSection;
// ── CMS page data ──
private PageSettingsDto? _pageData;
private LandingSettings? _settings;
// ── Top-selling product sections ──
private const int LandingTopProductCount = 6;
private List<Product> _topRegularProducts = new();
private List<DiscountProductCard> _topDiscountProducts = new();
private bool _loadingTopProducts = true;
@@ -47,6 +54,10 @@ public partial class Index : IDisposable
{
MainService.OnChangeHandler += OnStateChanged;
_isAuthenticated = await AuthService.IsAuthenticatedAsync();
_twentyCardPortalUrl = Configuration["TwentyCard:CreditPortalUrl"] ?? string.Empty;
// appsettings Landing:* — default false (hidden). Set true to restore sections.
_showStepsSection = Configuration.GetValue("Landing:ShowStepsSection", false);
_showFeaturesSection = Configuration.GetValue("Landing:ShowFeaturesSection", false);
// Load landing page settings from CMS
try
@@ -65,9 +76,9 @@ public partial class Index : IDisposable
PopulateFromSettings();
_dataLoaded = true;
// Load top-selling products and blog posts in parallel
var topRegTask = ProductService.GetTopSellingAsync(6);
var topDiscTask = DiscountProductService.GetTopSellingAsync(6);
// Load top-selling in-stock products and blog posts in parallel
var topRegTask = ProductService.GetTopSellingAsync(LandingTopProductCount, inStock: true);
var topDiscTask = DiscountProductService.GetTopSellingAsync(LandingTopProductCount, inStock: true);
var featuredPostsTask = BlogPostService.GetFeaturedPostsAsync(2);
try
@@ -160,7 +171,7 @@ public partial class Index : IDisposable
(Icons.Material.Outlined.VerifiedUser, "ثبت‌نام سریع و ساده", "در چند گام کوتاه حساب بسازید و شروع کنید.", 0),
(Icons.Material.Outlined.StarBorder, "پاداش‌های شفاف", "قوانین روشن، دسترسی آسان به سوابق و گزارش‌ها.", 100),
(Icons.Material.Outlined.Devices, "طراحی واکنش‌گرا", "تجربه‌ای روان در موبایل و دسکتاپ.", 200),
(Icons.Material.Outlined.Groups, "تیم‌سازی هوشمند", "ساختار درختی شبکه و مدیریت تیم‌های فروش.", 300),
(Icons.Material.Outlined.Groups, "تیم‌سازی هوشمند", "ساختار درختی سازمان و مدیریت تیم‌های فروش.", 300),
(Icons.Material.Outlined.Insights, "گزارش‌های لحظه‌ای", "داشبورد پویا برای مشاهده عملکرد و کمیسیون.", 400),
(Icons.Material.Outlined.Lock, "امنیت بالا", "رمزنگاری اطلاعات و احراز هویت چندمرحله‌ای.", 500),
};
@@ -191,9 +202,10 @@ public partial class Index : IDisposable
}
// ── Testimonials ──
if (_settings?.Testimonials?.Any() == true)
// اگر تنظیمات از CMS لود شده، لیست خالی یعنی ادمین عمداً حذف کرده — بدون fallback hardcode
if (_settings is not null)
{
_testimonials = _settings.Testimonials
_testimonials = (_settings.Testimonials ?? [])
.Select((t, i) => new TestimonialItem(t.Quote ?? "", t.Name ?? "", t.Role ?? "", i * 150))
.ToList();
}
@@ -295,6 +307,12 @@ public partial class Index : IDisposable
Navigation.NavigateTo(RouteConstants.Registration.Wizard);
}
private async Task OpenTwentyCardPortal()
{
if (string.IsNullOrWhiteSpace(_twentyCardPortalUrl)) return;
await JS.InvokeVoidAsync("open", _twentyCardPortalUrl, "_blank");
}
private void NavigateToPost(string slug)
{
Navigation.NavigateTo($"/blog/{slug}");
@@ -331,8 +349,10 @@ public partial class Index : IDisposable
public string? HeroButtonPrimaryText { get; set; }
public string? HeroButtonSecondaryText { get; set; }
public List<TrustBadgeItem>? TrustBadges { get; set; }
public bool? StepsEnabled { get; set; }
public string? StepsTitle { get; set; }
public List<StepItem>? Steps { get; set; }
public bool? FeaturesEnabled { get; set; }
public string? FeaturesTitle { get; set; }
public List<FeatureItem>? Features { get; set; }
public string? StatsTitle { get; set; }
@@ -1,4 +1,4 @@
using CMSMicroservice.Protobuf.Protos.UserAddress;
using FrontOffice.Main.Utilities;
using CMSMicroservice.Protobuf.Protos.UserAddress;
using FrontOffice.Main.Pages.Profile.Components;
using Microsoft.AspNetCore.Components;
@@ -9,6 +9,7 @@ namespace FrontOffice.Main.Pages.Profile;
public partial class Addresses : ComponentBase
{
[Inject] private UserAddressContract.UserAddressContractClient UserAddressContract { get; set; } = default!;
[Inject] private AuthService AuthService { get; set; } = default!;
private List<CustomerAddressModel> _addresses = new();
private bool _isLoadingAddresses;
@@ -42,7 +43,10 @@ public partial class Addresses : ComponentBase
var dialog = await DialogService.ShowAsync<AddAddressDialog>("افزودن آدرس جدید");
var result = await dialog.Result;
if (result is not null && !result.Canceled)
{
await LoadAddresses();
await AuthService.RefreshTokenAsync();
}
}
private async Task OpenEditAddressDialog(CustomerAddressModel address)
@@ -80,6 +84,7 @@ public partial class Addresses : ComponentBase
await UserAddressContract.DeleteCustomerAddressAsync(new() { Id = id });
Snackbar.Add("آدرس حذف شد.", Severity.Success);
await LoadAddresses();
await AuthService.RefreshTokenAsync();
}
catch (Exception ex)
{
@@ -0,0 +1,212 @@
@attribute [Route(RouteConstants.Profile.ChargeCreditWallet)]
@attribute [Authorize]
<PageTitle>شارژ کیف‌پول اصلی</PageTitle>
<MudContainer MaxWidth="MaxWidth.Medium" Class="py-6">
<MudStack Spacing="3">
<PageHeader Title="💳 شارژ کیف‌پول اصلی" BackHref="@RouteConstants.Profile.Wallet" />
@if (_paymentResult != null)
{
<MudAlert Severity="@(_paymentResult == "success" ? MudBlazor.Severity.Success : MudBlazor.Severity.Error)"
Class="rounded-lg" Variant="Variant.Filled">
@if (_paymentResult == "success")
{
<MudStack Spacing="2">
<span>شارژ کیف پول اصلی با موفقیت انجام شد! ✅</span>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.ShoppingCart"
OnClick="ContinueShoppingAsync">
ادامه خرید
</MudButton>
</MudStack>
}
else if (_paymentResult == "cancelled")
{
<span>پرداخت توسط شما لغو شد.</span>
}
else
{
<span>پرداخت ناموفق بود. لطفاً دوباره تلاش کنید.</span>
}
</MudAlert>
}
@if (_authLoaded && _redirectingToMagic)
{
<MudAlert Severity="MudBlazor.Severity.Info" Class="rounded-lg" Variant="Variant.Outlined">
<MudStack Spacing="2" AlignItems="AlignItems.Center">
<MudProgressCircular Size="Size.Small" Indeterminate="true" Color="Color.Primary" />
<MudText>
کیف پول شما در حالت جادویی است. در حال انتقال به صفحه شارژ کیف پول جادویی...
</MudText>
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary"
Href="@RouteConstants.Profile.MagicWallet"
StartIcon="@Icons.Material.Filled.AutoAwesome">
رفتن به شارژ کیف جادویی
</MudButton>
</MudStack>
</MudAlert>
}
else if (_authLoaded && !_isClubMemberActive)
{
<MudAlert Severity="MudBlazor.Severity.Warning" Class="rounded-lg" Variant="Variant.Outlined">
<MudStack Spacing="2">
<MudText>
برای شارژ کیف پول اصلی ابتدا باید پکیج را خریداری کرده و قرارداد باشگاه مشتریان را امضا کنید.
</MudText>
<MudStack Row="true" Spacing="1" Class="flex-wrap">
@if (!_hasPurchasedPackage)
{
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary"
Href="@RouteConstants.Package.List"
StartIcon="@Icons.Material.Filled.CardGiftcard">
مشاهده پکیج‌ها
</MudButton>
}
else
{
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary"
Href="@RouteConstants.Club.Membership"
StartIcon="@Icons.Material.Filled.Handshake">
امضای قرارداد باشگاه
</MudButton>
}
<MudButton Size="Size.Small" Variant="Variant.Text"
Href="@RouteConstants.Profile.Wallet">
بازگشت به کیف پول
</MudButton>
</MudStack>
</MudStack>
</MudAlert>
}
else if (_authLoaded && _needsMagicCeilingConsent && !_allowNormalDespiteMagic && !_redirectingToMagic)
{
<MudAlert Severity="MudBlazor.Severity.Warning" Class="rounded-lg" Variant="Variant.Outlined">
<MudStack Spacing="3">
<MudText Typo="Typo.subtitle1">
سقف شارژ کیف پول جادویی در این دور پر شده است.
</MudText>
<MudText Typo="Typo.body2">
شارژ از این صفحه بدون ضریب (۱:۱) به موجودی اضافه می‌شود و ضریب جادویی اعمال نمی‌شود.
برای ادامه، تأیید کنید.
</MudText>
<MudStack Row="true" Spacing="1" Class="flex-wrap">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Check"
OnClick="AcceptMagicCeilingConsent">
ادامه شارژ بدون ضریب
</MudButton>
<MudButton Variant="Variant.Outlined"
Color="Color.Secondary"
StartIcon="@Icons.Material.Filled.AutoAwesome"
OnClick="DeclineMagicCeilingConsent">
بازگشت به کیف جادویی
</MudButton>
</MudStack>
</MudStack>
</MudAlert>
}
else if (ShowChargeForm)
{
<MudPaper Elevation="2" Class="pa-5 rounded-lg">
<MudText Typo="Typo.h6" Class="mb-3">
<MudIcon Icon="@Icons.Material.Filled.AccountBalanceWallet" Class="ml-1" />
شارژ موجودی اصلی
</MudText>
@if (_allowNormalDespiteMagic)
{
<MudAlert Severity="MudBlazor.Severity.Warning" Dense="true" Class="mb-3" Icon="@Icons.Material.Filled.Warning">
این شارژ بدون ضریب جادویی است (۱:۱). سقف واریز جادویی شما در این دور پر شده است.
</MudAlert>
}
<MudAlert Severity="MudBlazor.Severity.Info" Dense="true" Class="mb-3" Icon="@Icons.Material.Filled.Info">
مبلغ وارد شده (به تومان) از طریق درگاه پرداخت به موجودی اصلی شما اضافه می‌شود.
از موجودی اصلی برای خرید از فروشگاه عادی و پرداخت سفارش استفاده می‌شود.
</MudAlert>
<MudStack Spacing="2">
<MudNumericField @bind-Value="_chargeAmount"
Label="مبلغ (تومان)"
Variant="Variant.Outlined"
Min="10_000"
Max="1_000_000_000"
Format="N0"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Payments"
HelperText="حداقل ۱۰,۰۰۰ و حداکثر ۱,۰۰۰,۰۰۰,۰۰۰ تومان" />
<MudStack Row="true" Spacing="1" Class="flex-wrap">
@foreach (var preset in _presetAmounts)
{
<MudButton Variant="Variant.Outlined" Size="Size.Small"
Color="@(_chargeAmount == preset ? Color.Primary : Color.Default)"
OnClick="() => _chargeAmount = preset"
Class="rounded-pill">
@FormatToman(preset)
</MudButton>
}
</MudStack>
@if (_chargeAmount > 0)
{
<MudPaper Outlined="true" Class="pa-3 rounded-lg" Style="background: #eff6ff;">
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2">مبلغ پرداخت:</MudText>
<MudText Typo="Typo.body2"><strong>@FormatToman(_chargeAmount)</strong></MudText>
</MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2">موجودی دریافتی:</MudText>
<MudText Typo="Typo.body2" Color="Color.Primary"><strong>@FormatToman(_chargeAmount)</strong></MudText>
</MudStack>
</MudPaper>
}
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
Size="Size.Large"
FullWidth="true"
Disabled="@(_isProcessing || _chargeAmount < 10_000)"
OnClick="StartCreditCharge"
StartIcon="@Icons.Material.Filled.Payment"
Class="rounded-lg mt-2">
@if (_isProcessing)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="me-2" />
<span>در حال انتقال به درگاه...</span>
}
else
{
<span>پرداخت با کارت بانکی</span>
}
</MudButton>
</MudStack>
</MudPaper>
<MudExpansionPanels Elevation="1" Class="rounded-lg">
<MudExpansionPanel Text="قوانین شارژ کیف پول اصلی" MaxHeight="500" IsInitiallyExpanded="false">
<MudList T="string" Dense="true">
<MudListItem T="string" Icon="@Icons.Material.Filled.CheckCircle" IconColor="Color.Success">
موجودی اصلی برای خرید از فروشگاه عادی و پرداخت سفارش قابل استفاده است
</MudListItem>
<MudListItem T="string" Icon="@Icons.Material.Filled.CheckCircle" IconColor="Color.Success">
حداقل مبلغ شارژ: ۱۰,۰۰۰ تومان
</MudListItem>
<MudListItem T="string" Icon="@Icons.Material.Filled.CheckCircle" IconColor="Color.Success">
مبلغ واریزی بدون ضریب به موجودی اضافه می‌شود (۱:۱)
</MudListItem>
<MudListItem T="string" Icon="@Icons.Material.Filled.Info" IconColor="Color.Info">
پرداخت از طریق درگاه بانکی انجام می‌شود
</MudListItem>
</MudList>
</MudExpansionPanel>
</MudExpansionPanels>
}
</MudStack>
</MudContainer>
@@ -0,0 +1,199 @@
using FrontOffice.Main.Utilities;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using MudBlazor;
namespace FrontOffice.Main.Pages.Profile;
public partial class ChargeCreditWallet : ComponentBase
{
[Inject] private AuthService AuthService { get; set; } = default!;
private bool _isProcessing;
private long _chargeAmount;
private string? _paymentResult;
private string? _pendingReturnUrl;
private bool _isClubMemberActive;
private bool _hasPurchasedPackage;
private bool _authLoaded;
private bool _redirectingToMagic;
private bool _allowNormalDespiteMagic;
private bool _needsMagicCeilingConsent;
private readonly long[] _presetAmounts = { 500_000, 1_000_000, 5_000_000, 10_000_000, 20_000_000, 50_000_000 };
[SupplyParameterFromQuery(Name = "payment")]
public string? PaymentQueryParam { get; set; }
[SupplyParameterFromQuery(Name = "amount")]
public long? AmountQueryParam { get; set; }
[SupplyParameterFromQuery(Name = "returnUrl")]
public string? ReturnUrlQueryParam { get; set; }
private bool ShowChargeForm =>
_authLoaded
&& _isClubMemberActive
&& !_redirectingToMagic
&& (!_needsMagicCeilingConsent || _allowNormalDespiteMagic);
protected override async Task OnInitializedAsync()
{
_paymentResult = PaymentQueryParam;
if (AmountQueryParam is > 0)
_chargeAmount = CreditChargeNavigation.NormalizeChargeAmount(AmountQueryParam.Value);
if (CreditChargeNavigation.IsValidReturnUrl(ReturnUrlQueryParam))
_pendingReturnUrl = ReturnUrlQueryParam;
try
{
var userInfo = await AuthService.GetUserAuthInfo();
_isClubMemberActive = userInfo.IsClubMemberActive;
_hasPurchasedPackage = userInfo.HasPurchasedPackage;
}
catch
{
_isClubMemberActive = false;
_hasPurchasedPackage = false;
}
finally
{
_authLoaded = true;
}
if (_isClubMemberActive)
await EvaluateMagicWalletGateAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && !string.IsNullOrEmpty(_pendingReturnUrl) && _isClubMemberActive && !_redirectingToMagic)
await CreditChargeNavigation.SaveReturnUrlAsync(JSRuntime, _pendingReturnUrl);
await base.OnAfterRenderAsync(firstRender);
}
private void AcceptMagicCeilingConsent()
{
_allowNormalDespiteMagic = true;
_redirectingToMagic = false;
}
private void DeclineMagicCeilingConsent()
{
_redirectingToMagic = true;
Navigation.NavigateTo(RouteConstants.Profile.MagicWallet);
}
private async Task StartCreditCharge()
{
if (!_isClubMemberActive)
{
Snackbar.Add(
"برای شارژ کیف پول اصلی ابتدا باید پکیج را خریداری کرده و قرارداد باشگاه مشتریان را امضا کنید.",
Severity.Warning);
return;
}
if (await EvaluateMagicWalletGateAsync())
return;
if (_needsMagicCeilingConsent && !_allowNormalDespiteMagic)
return;
if (_chargeAmount <= 0 || _isProcessing) return;
_isProcessing = true;
StateHasChanged();
try
{
if (!string.IsNullOrEmpty(_pendingReturnUrl))
await CreditChargeNavigation.SaveReturnUrlAsync(JSRuntime, _pendingReturnUrl);
else
{
var storedReturnUrl = await CreditChargeNavigation.GetReturnUrlAsync(JSRuntime);
if (CreditChargeNavigation.IsValidReturnUrl(storedReturnUrl))
_pendingReturnUrl = storedReturnUrl;
}
var (success, gatewayUrl, error) = await WalletService.InitiateCreditChargeAsync(_chargeAmount);
if (success && !string.IsNullOrEmpty(gatewayUrl))
{
Navigation.NavigateTo(gatewayUrl, forceLoad: true);
}
else
{
Snackbar.Add(error ?? "خطا در ایجاد درخواست پرداخت", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
}
finally
{
_isProcessing = false;
StateHasChanged();
}
}
/// <summary>
/// Magic با ظرفیت باقی‌مانده → ریدایرکت به MagicWallet.
/// Magic با سقف پر → نمایش تأیید روی صفحه برای شارژ ۱:۱.
/// true یعنی مسیر شارژ فعلی باید متوقف شود.
/// </summary>
private async Task<bool> EvaluateMagicWalletGateAsync()
{
if (_allowNormalDespiteMagic)
return false;
try
{
var status = await WalletService.GetMagicWalletStatusAsync();
if (status.WalletMode != 1)
{
_needsMagicCeilingConsent = false;
return false;
}
if (status.MagicRemainingDeposit > 0)
{
var multiplier = status.MagicMultiplier > 0 ? status.MagicMultiplier : 2.5;
var multiplierText = multiplier.ToString("0.#");
_redirectingToMagic = true;
_needsMagicCeilingConsent = false;
Snackbar.Add(
$"کیف پول شما در حالت جادویی است. شارژ از این صفحه موجودی را چندبرابر نمی‌کند. برای دریافت ضریب جادویی (×{multiplierText}) به صفحه شارژ کیف پول جادویی هدایت می‌شوید.",
Severity.Info);
Navigation.NavigateTo(RouteConstants.Profile.MagicWallet);
return true;
}
_needsMagicCeilingConsent = true;
return !_allowNormalDespiteMagic;
}
catch
{
// در صورت خطا در خواندن وضعیت، مانع شارژ عادی نشو
return false;
}
}
private async Task ContinueShoppingAsync()
{
var returnUrl = await CreditChargeNavigation.GetReturnUrlAsync(JSRuntime);
await CreditChargeNavigation.ClearReturnUrlAsync(JSRuntime);
if (CreditChargeNavigation.IsValidReturnUrl(returnUrl))
Navigation.NavigateTo(returnUrl!);
}
private static string FormatToman(long toman)
=> string.Format("{0:N0} تومان", toman);
}
@@ -7,6 +7,16 @@
<MudStack Spacing="3">
<PageHeader Title="💳 شارژ کیف‌پول اعتباری" BackHref="@RouteConstants.Profile.Wallet" />
@* Gateway self-charge temporarily disabled. To restore: remove the alert and set _gatewayChargeEnabled = true in .cs (or flip the if below). *@
<MudAlert Severity="MudBlazor.Severity.Info" Class="rounded-lg" Variant="Variant.Outlined">
شارژ کیف‌پول اعتباری از طریق درگاه فعلاً غیرفعال است.
<MudButton Size="Size.Small" Variant="Variant.Text" Href="@RouteConstants.Profile.Wallet" Class="mt-1">
بازگشت به کیف پول
</MudButton>
</MudAlert>
@if (false) @* TEMP: set to true to restore gateway charge UI *@
{
@* ─── نتیجه پرداخت (اگه از callback برگشته) ─── *@
@if (_paymentResult != null)
{
@@ -117,5 +127,6 @@
</MudList>
</MudExpansionPanel>
</MudExpansionPanels>
}
</MudStack>
</MudContainer>
@@ -52,20 +52,21 @@
<div id="org-chart-container" class="org-chart-container"></div>
}
@* Stats Bar *@
@* Stats Bar — فقط آمار خود کاربر (نه نودهای درخت) *@
@if (_statistics != null && !_isLoading)
{
<div class="org-chart-stats">
<div class="org-chart-stats-row">
<div class="stat-item">
<span class="stat-label">کل اعضا:</span>
<span class="stat-value">@_statistics.TotalMembers</span>
</div>
<div class="stat-item">
<span class="stat-label">پای چپ:</span>
<span class="stat-label">سازمان چپ:</span>
<span class="stat-value left">@_statistics.LeftLegCount</span>
</div>
<div class="stat-item">
<span class="stat-label">پای راست:</span>
<span class="stat-label">سازمان راست:</span>
<span class="stat-value right">@_statistics.RightLegCount</span>
</div>
<div class="stat-item">
@@ -77,6 +78,23 @@
<span class="stat-value">@(_statistics.WeakerLeg == "Left" ? "چپ" : "راست")</span>
</div>
</div>
<div class="org-chart-stats-row leg-scores">
<div class="stat-item leg-score-gold">
<span class="stat-label">پکیج طلایی:</span>
<span class="stat-value left">چپ @_statistics.GoldLeftLegTotal</span>
<span class="stat-sep">·</span>
<span class="stat-value right">راست @_statistics.GoldRightLegTotal</span>
</div>
</div>
<div class="org-chart-stats-row leg-scores">
<div class="stat-item leg-score-silver">
<span class="stat-label">پکیج نقره‌ای:</span>
<span class="stat-value left">چپ @_statistics.SilverLeftLegTotal</span>
<span class="stat-sep">·</span>
<span class="stat-value right">راست @_statistics.SilverRightLegTotal</span>
</div>
</div>
</div>
}
</div>
@@ -1,4 +1,5 @@
using FrontOffice.Main.Utilities;
using DateTimeConverterCL;
using FrontOffice.Main.Utilities;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
@@ -95,7 +96,25 @@ public partial class OrganizationChart : IAsyncDisposable
try
{
_dotNetHelper = DotNetObjectReference.Create(this);
var flatData = _networkTree.ToFlatArray();
// Explicit camelCase mapping (same pattern as BackOffice admin chart)
var flatData = _networkTree.ToFlatArray().Select(n => new
{
id = n.Id,
parentId = n.ParentId,
fullName = n.FullName,
mobile = n.Mobile,
avatar = n.Avatar,
position = n.Position,
level = n.Level,
isActive = n.IsActive,
isClubActive = n.IsClubActive,
activationWeekNumber = n.ActivationWeekNumber,
registeredAt = n.JoinedAt.HasValue
? n.JoinedAt.Value.ToLocalTime().MiladiToJalali()
: "",
referralCode = n.ReferralCode,
packageName = n.PackageName ?? string.Empty
}).ToArray();
await JSRuntime.InvokeVoidAsync("OrgChart.init", "org-chart-container", flatData, _dotNetHelper);
}
@@ -46,6 +46,21 @@
</MudPaper>
</MudLink>
<MudLink Href="@RouteConstants.Profile.Invitees" Underline="Underline.None">
<MudPaper Elevation="0" Class="pa-4 rounded-xl profile-hub-item">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
<MudIcon Icon="@Icons.Material.Outlined.GroupAdd" Color="Color.Info" />
<MudStack Spacing="0">
<MudText Typo="Typo.subtitle2">دعوت‌شدگان من</MudText>
<MudText Typo="Typo.caption" Class="mud-text-secondary">کسانی که با موبایل شما ثبت‌نام کرده‌اند</MudText>
</MudStack>
</MudStack>
<MudIcon Icon="@Icons.Material.Filled.ChevronLeft" Class="mud-text-secondary" />
</MudStack>
</MudPaper>
</MudLink>
<MudLink Href="@RouteConstants.Profile.Settings" Underline="Underline.None">
<MudPaper Elevation="0" Class="pa-4 rounded-xl profile-hub-item">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
@@ -109,6 +109,13 @@
{
<MudText Typo="Typo.caption" Color="Color.Success" Class="mt-2">@(_copyMessage)</MudText>
}
<MudButton Variant="Variant.Text"
Color="Color.Primary"
Class="mt-3"
Href="@RouteConstants.Profile.Invitees"
StartIcon="@Icons.Material.Filled.GroupAdd">
مشاهده دعوت‌شدگان من
</MudButton>
</MudPaper>
}
else
@@ -121,6 +128,13 @@
<MudText Typo="Typo.body2" Class="mud-text-secondary mb-1" Style="max-width:560px; margin:0 auto;">
برای فعال‌سازی لینک دعوت، ابتدا یکی از <strong>پکیج‌ها</strong> را تهیه کنید و سپس در <strong>باشگاه مشتریان</strong> عضو شوید.
</MudText>
<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
Class="mt-3"
Href="@RouteConstants.Profile.Invitees"
StartIcon="@Icons.Material.Filled.GroupAdd">
دعوت‌شدگان من
</MudButton>
<MudText Typo="Typo.body2" Class="mud-text-secondary mb-4" Style="max-width:560px; margin:0 auto;">
از مزایای ویژه عضویت بهره‌مند شده و با فعالیت در زمینه توسعه فروشگاه‌ها پاداش دریافت کنید.
</MudText>
@@ -333,6 +333,7 @@ public partial class Index
if (!result.Canceled)
{
await LoadAddresses();
await AuthService.RefreshTokenAsync();
}
}
@@ -385,6 +386,7 @@ public partial class Index
});
Snackbar.Add("آدرس با موفقیت حذف شد.", Severity.Success);
await LoadAddresses();
await AuthService.RefreshTokenAsync();
}
catch (Exception ex)
{
@@ -419,7 +421,10 @@ public partial class Index
switch (purchase.Method)
{
case Shared.PackagePurchaseDialog.PaymentMethodType.DirectPayment:
await ProcessDirectPayment(purchase.Package.Id);
await ProcessDirectPayment(purchase.Package.Id, gatewayProvider: null);
break;
case Shared.PackagePurchaseDialog.PaymentMethodType.TwentyCard:
await ProcessTwentyCardPayment(purchase.Package.Id);
break;
case Shared.PackagePurchaseDialog.PaymentMethodType.DayaLoan:
await ProcessDayaLoanPayment();
@@ -428,7 +433,25 @@ public partial class Index
}
}
private async Task ProcessDirectPayment(long packageId)
private async Task ProcessTwentyCardPayment(long packageId)
{
var options = new DialogOptions
{
MaxWidth = MaxWidth.Small,
FullWidth = true,
CloseOnEscapeKey = true,
BackdropClick = true
};
var dialog = await DialogService.ShowAsync<Shared.TwentyCardCreditDialog>(
string.Empty, options);
var result = await dialog.Result;
if (result.Canceled) return;
await ProcessDirectPayment(packageId, gatewayProvider: "20card");
}
private async Task ProcessDirectPayment(long packageId, string? gatewayProvider = null)
{
if (_isProcessingPayment) return;
@@ -437,11 +460,15 @@ public partial class Index
try
{
var response = await PackageContract.CustomerPurchasePackageAsync(new CustomerPurchasePackageRequest
var request = new CustomerPurchasePackageRequest
{
PackageId = packageId,
PurchaseMethod = PurchaseMethodEnum.PurchaseMethodGateway
});
};
if (!string.IsNullOrWhiteSpace(gatewayProvider))
request.GatewayProvider = gatewayProvider;
var response = await PackageContract.CustomerPurchasePackageAsync(request);
if (!response.Success)
{
@@ -491,11 +518,6 @@ public partial class Index
Snackbar.Add("لطفا اطلاعات شخصی خود را تکمیل کنید. (تاریخ تولد وارد نشده)", Severity.Error);
return;
}
if (!_addresses.Any())
{
Snackbar.Add("آدرس محل سکونت شما الزامی است!", Severity.Error);
return;
}
var url = "https://dayadiamond.ir/profile/creditpurchase/?merchantcode=56146364";
await JSRuntime.InvokeVoidAsync("open", url, "_blank");
@@ -0,0 +1,157 @@
@attribute [Authorize]
@attribute [Route(RouteConstants.Profile.Invitees)]
@using CMSMicroservice.Protobuf.Protos.User
@using CMSMicroservice.Protobuf.Protos.City
@inject UserContract.UserContractClient UserClient
@inject ISnackbar Snackbar
<PageTitle>دعوت‌شدگان من | کارا بازار سلامت</PageTitle>
<MudContainer MaxWidth="MaxWidth.Medium" Class="py-6">
<PageHeader Title="دعوت‌شدگان من" BackHref="@RouteConstants.Profile.Index" />
<MudText Typo="Typo.body2" Class="mud-text-secondary mb-4">
افرادی که با شماره موبایل شما به‌عنوان دعوت‌کننده ثبت‌نام کرده‌اند (متفاوت از سازمان فروش).
</MudText>
@if (_loading)
{
<MudStack AlignItems="AlignItems.Center" Class="py-8">
<MudProgressCircular Indeterminate="true" Color="Color.Primary" />
</MudStack>
}
else
{
<MudGrid Spacing="3" Class="mb-4">
<MudItem xs="12" sm="4">
<MudPaper Class="pa-4 rounded-xl" Elevation="1">
<MudText Typo="Typo.caption" Class="mud-text-secondary">کل دعوت‌ها</MudText>
<MudText Typo="Typo.h5">@_stats.TotalInvitees</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="4">
<MudPaper Class="pa-4 rounded-xl" Elevation="1">
<MudText Typo="Typo.caption" Class="mud-text-secondary">فعال در باشگاه</MudText>
<MudText Typo="Typo.h5" Color="Color.Success">@_stats.ClubActiveCount</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="4">
<MudPaper Class="pa-4 rounded-xl" Elevation="1">
<MudText Typo="Typo.caption" Class="mud-text-secondary">غیرفعال باشگاه</MudText>
<MudText Typo="Typo.h5" Color="Color.Warning">@_stats.ClubInactiveCount</MudText>
</MudPaper>
</MudItem>
</MudGrid>
<MudPaper Class="pa-3 rounded-xl mb-3" Elevation="0">
<MudSelect T="string" Label="فیلتر وضعیت باشگاه" Value="_clubFilter" ValueChanged="OnFilterChanged"
Variant="Variant.Outlined" Dense="true" Margin="Margin.Dense">
<MudSelectItem Value="@("ALL")">همه</MudSelectItem>
<MudSelectItem Value="@("ACTIVE")">فعال باشگاه</MudSelectItem>
<MudSelectItem Value="@("INACTIVE")">غیرفعال باشگاه</MudSelectItem>
</MudSelect>
</MudPaper>
@if (!_invitees.Any())
{
<MudAlert Severity="Severity.Info" Dense="true">هنوز کسی با کد دعوت شما ثبت‌نام نکرده است.</MudAlert>
}
else
{
<MudTable Items="_invitees" Hover="true" Dense="true" Breakpoint="Breakpoint.Sm" Class="rounded-xl">
<HeaderContent>
<MudTh>نام</MudTh>
<MudTh>تاریخ ثبت‌نام</MudTh>
<MudTh>باشگاه</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="نام">
@(string.IsNullOrWhiteSpace($"{context.FirstName} {context.LastName}".Trim())
? "—"
: $"{context.FirstName} {context.LastName}".Trim())
</MudTd>
<MudTd DataLabel="تاریخ ثبت‌نام">
@FormatDate(context.RegisteredAt)
</MudTd>
<MudTd DataLabel="باشگاه">
@if (context.IsClubActive)
{
<MudChip T="string" Size="Size.Small" Color="Color.Success" Variant="Variant.Filled">فعال</MudChip>
}
else
{
<MudChip T="string" Size="Size.Small" Color="Color.Default" Variant="Variant.Outlined">غیرفعال</MudChip>
}
</MudTd>
</RowTemplate>
</MudTable>
@if (_totalPages > 1)
{
<MudStack AlignItems="AlignItems.Center" Class="mt-4">
<MudPagination Count="_totalPages" Selected="_page" SelectedChanged="OnPageChanged" Color="Color.Primary" />
</MudStack>
}
}
}
</MudContainer>
@code {
private bool _loading = true;
private string _clubFilter = "ALL";
private int _page = 1;
private const int PageSize = 10;
private int _totalPages;
private InviteeStats _stats = new();
private List<InviteeModel> _invitees = new();
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
private async Task OnFilterChanged(string value)
{
_clubFilter = value;
_page = 1;
await LoadAsync();
}
private async Task OnPageChanged(int page)
{
_page = page;
await LoadAsync();
}
private async Task LoadAsync()
{
_loading = true;
try
{
var response = await UserClient.GetMyInviteesAsync(new GetMyInviteesRequest
{
PaginationState = new PaginationState { PageNumber = _page, PageSize = PageSize },
ClubStatusFilter = _clubFilter
});
_stats = response.Stats ?? new InviteeStats();
_invitees = response.Invitees.ToList();
_totalPages = (int)(response.MetaData?.TotalPage ?? 0);
if (_totalPages < 1 && _invitees.Count > 0) _totalPages = 1;
}
catch (Exception ex)
{
Snackbar.Add($"خطا در بارگذاری دعوت‌شدگان: {ex.Message}", Severity.Error);
}
finally
{
_loading = false;
}
}
private static string FormatDate(Google.Protobuf.WellKnownTypes.Timestamp? ts)
{
if (ts == null) return "—";
return ts.ToDateTime().ToLocalTime().MiladiToJalali();
}
}
@@ -166,8 +166,23 @@
else
{
<MudAlert Severity="MudBlazor.Severity.Warning" Class="rounded-lg">
<MudStack Spacing="2">
<MudText>
سقف شارژ جادویی در این دور پر شده است.
با خرج کردن موجودی و خرید مجدد پکیج، سقف ریست می‌شود.
</MudText>
<MudText Typo="Typo.body2">
اگر برای تکمیل خرید به موجودی بیشتری نیاز دارید، می‌توانید به‌صورت استثنایی کیف اصلی را بدون ضریب (۱:۱) شارژ کنید.
</MudText>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.AccountBalanceWallet"
Href="@RouteConstants.Profile.ChargeCreditWallet"
Class="rounded-lg align-self-start">
شارژ عادی بدون ضریب
</MudButton>
</MudStack>
</MudAlert>
}
@@ -86,6 +86,10 @@
[SupplyParameterFromQuery(Name = "Status")]
private string? Status { get; set; }
/// <summary>۲۰کارت callback — هم‌معنی Status زرین‌پال</summary>
[SupplyParameterFromQuery(Name = "PaymentStatus")]
private string? PaymentStatus { get; set; }
private bool _isLoading = true;
private bool _isSuccess;
private string _message = string.Empty;
@@ -132,6 +136,10 @@
{
try
{
// ۲۰کارت: PaymentStatus؛ زرین‌پال: Status
if (string.IsNullOrWhiteSpace(Status) && !string.IsNullOrWhiteSpace(PaymentStatus))
Status = PaymentStatus;
// تعیین نوع پرداخت — اگر type نباشد، پیش‌فرض خرید پکیج
var type = PaymentType?.ToLowerInvariant() ?? "package";
@@ -143,6 +151,9 @@
case "discount-wallet":
await VerifyDiscountWalletCharge();
break;
case "credit-wallet":
await VerifyCreditWalletCharge();
break;
case "discount-order":
await VerifyDiscountOrderPayment();
break;
@@ -255,6 +266,35 @@
}
}
/// <summary>
/// تأیید شارژ کیف پول اصلی
/// </summary>
private async Task VerifyCreditWalletCharge()
{
_successReturnUrl = "/profile/charge-credit-wallet?payment=success";
_successReturnText = "بازگشت به شارژ کیف پول اصلی";
_failReturnUrl = "/profile/charge-credit-wallet?payment=failed";
_failReturnText = "بازگشت به شارژ کیف پول اصلی";
if (string.IsNullOrEmpty(Authority))
{
_isSuccess = false;
_message = "کد Authority نامعتبر است";
return;
}
var (success, message) = await WalletService.VerifyCreditChargeAsync(
Authority, Status ?? "NOK");
_isSuccess = success;
_message = message;
if (_isSuccess)
{
await UpdateWalletBalance();
}
}
/// <summary>
/// تأیید پرداخت سفارش فروشگاه تخفیفی
/// </summary>
@@ -308,6 +348,7 @@
{
"magic-wallet" => "/profile/magic-wallet",
"discount-wallet" => "/profile/charge-discount-wallet",
"credit-wallet" => "/profile/charge-credit-wallet",
"discount-order" => "/discount-store",
_ => "/profile"
};
@@ -38,6 +38,94 @@
درخواست‌های برداشت
</MudButton>
<!-- دکمه شارژ کیف‌پول اصلی / جادویی -->
@if (_isClubMemberActive)
{
@if (_isMagicWallet)
{
<MudButton Variant="Variant.Filled"
Color="Color.Secondary"
Size="Size.Large"
FullWidth="true"
StartIcon="@Icons.Material.Filled.AutoAwesome"
Href="@RouteConstants.Profile.MagicWallet"
Class="rounded-lg">
شارژ کیف‌پول جادویی
</MudButton>
@if (_magicCeilingFull)
{
<MudAlert Severity="Severity.Warning" Dense="true" Variant="Variant.Outlined">
سقف شارژ جادویی این دور پر است. در صورت نیاز می‌توانید به‌صورت استثنایی بدون ضریب شارژ کنید.
</MudAlert>
<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
Size="Size.Large"
FullWidth="true"
StartIcon="@Icons.Material.Filled.AccountBalanceWallet"
Href="@RouteConstants.Profile.ChargeCreditWallet"
Class="rounded-lg">
شارژ عادی بدون ضریب
</MudButton>
}
else
{
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined">
کیف پول شما در حالت جادویی است؛ شارژ از مسیر جادویی با ضریب چندبرابر اعمال می‌شود.
</MudAlert>
}
}
else
{
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
Size="Size.Large"
FullWidth="true"
StartIcon="@Icons.Material.Filled.AccountBalanceWallet"
Href="@RouteConstants.Profile.ChargeCreditWallet"
Class="rounded-lg">
شارژ کیف‌پول اصلی
</MudButton>
}
}
else
{
<MudPaper Elevation="0" Class="pa-3 rounded-lg" Outlined="true">
<MudStack Spacing="2">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
Size="Size.Large"
FullWidth="true"
StartIcon="@Icons.Material.Filled.AccountBalanceWallet"
Disabled="true"
Class="rounded-lg">
شارژ کیف‌پول اصلی
</MudButton>
<MudAlert Severity="Severity.Warning" Dense="true" Variant="Variant.Outlined">
برای شارژ کیف پول اصلی ابتدا باید پکیج را خریداری کرده و قرارداد باشگاه مشتریان را امضا کنید.
</MudAlert>
<MudStack Row="true" Spacing="1" Class="flex-wrap">
@if (!_hasPurchasedPackage)
{
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary"
Href="@RouteConstants.Package.List"
StartIcon="@Icons.Material.Filled.CardGiftcard">
مشاهده پکیج‌ها
</MudButton>
}
else
{
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary"
Href="@RouteConstants.Club.Membership"
StartIcon="@Icons.Material.Filled.Handshake">
امضای قرارداد باشگاه
</MudButton>
}
</MudStack>
</MudStack>
</MudPaper>
}
@* ── Temporarily hidden: discount-wallet gateway charge (restore when needed)
<!-- دکمه شارژ کیف‌پول اعتباری -->
<MudButton Variant="Variant.Filled"
Color="Color.Info"
@@ -48,6 +136,7 @@
Class="rounded-lg">
شارژ کیف‌پول اعتباری
</MudButton>
── *@
<MudPaper Elevation="2" Class="pa-4 rounded-lg">
<MudText Typo="Typo.h6" Class="mb-2">تراکنش‌ها و مسیرهای شارژ</MudText>
@@ -6,13 +6,43 @@ namespace FrontOffice.Main.Pages.Profile;
public partial class Wallet : ComponentBase
{
[Inject] private AuthService AuthService { get; set; } = default!;
private (string Credit, string Discount, string Network) _balances = ("-", "-", "-");
private List<WalletTransaction> _txs = new();
private string? _filterReferenceId;
private string _filterType = "all";
private bool _isClubMemberActive;
private bool _hasPurchasedPackage;
private bool _isMagicWallet;
private bool _magicCeilingFull;
protected override async Task OnInitializedAsync()
{
try
{
var userInfo = await AuthService.GetUserAuthInfo();
_isClubMemberActive = userInfo.IsClubMemberActive;
_hasPurchasedPackage = userInfo.HasPurchasedPackage;
}
catch
{
_isClubMemberActive = false;
_hasPurchasedPackage = false;
}
try
{
var magicStatus = await WalletService.GetMagicWalletStatusAsync();
_isMagicWallet = magicStatus.WalletMode == 1;
_magicCeilingFull = _isMagicWallet && magicStatus.MagicRemainingDeposit <= 0;
}
catch
{
_isMagicWallet = false;
_magicCeilingFull = false;
}
var b = await WalletService.GetBalancesAsync();
_balances = (FormatPrice(b.CreditBalance), FormatPrice(b.DiscountBalance), FormatPrice(b.NetworkBalance));
_txs = await WalletService.GetTransactionsAsync();
@@ -58,8 +58,19 @@ public partial class WithdrawalRequests : ComponentBase
try
{
_isSubmittingWithdrawal = true;
_withdrawIban=_withdrawIban!.Trim().ToUpper().Replace("IR", "").Replace(" ", "");
await WalletService.RequestWithdrawalAsync(_selectedPayout.Id, _withdrawMethod, "IR"+_withdrawIban);
string? normalizedIban = null;
if (_withdrawMethod == WithdrawalMethodClient.Cash)
{
normalizedIban = NormalizeIranianIban(_withdrawIban);
if (normalizedIban is null)
{
Snackbar.Add("فرمت شماره شبا معتبر نیست. باید IR و ۲۴ رقم باشد (خط تیره اختیاری است).", Severity.Warning);
return;
}
}
await WalletService.RequestWithdrawalAsync(_selectedPayout.Id, _withdrawMethod, normalizedIban);
Snackbar.Add("درخواست برداشت ثبت شد.", Severity.Success);
// بروزرسانی لیست
@@ -125,4 +136,26 @@ public partial class WithdrawalRequests : ComponentBase
1 => "الماس",
_ => "-"
};
/// <summary>
/// فاصله و خط تیره را حذف می‌کند و شبا را به شکل IR + ۲۴ رقم برمی‌گرداند.
/// </summary>
private static string? NormalizeIranianIban(string? iban)
{
if (string.IsNullOrWhiteSpace(iban))
return null;
var normalized = iban.Trim().ToUpperInvariant()
.Replace(" ", "", StringComparison.Ordinal)
.Replace("-", "", StringComparison.Ordinal);
if (normalized.StartsWith("IR", StringComparison.Ordinal))
normalized = normalized[2..];
normalized = "IR" + normalized;
return System.Text.RegularExpressions.Regex.IsMatch(normalized, @"^IR\d{24}$")
? normalized
: null;
}
}
@@ -319,17 +319,17 @@ public partial class RegisterWizard
$"<p><span class='field-label'>شماره ملی:</span> {_model.NationalCode?.PersianToEnglish()}</p>" +
$"<p><span class='field-label'>شماره تماس:</span> {_userAuthInfo.MobileNumber?.PersianToEnglish()}</p></div>" +
"<p>کارابازار و کاربر که در این قرارداد جداگانه &laquo;طرف&raquo; و مجموعاً &laquo;طرفین&raquo; نامیده می‌شوند، با علم و آگاهی کامل، مفاد زیر را پذیرفته و امضا می‌نمایند.</p></div><div><p class='section-title'>ماده ۱ - موضوع قرارداد</p>" +
"<p>موضوع این قرارداد، تعیین حدود و چارچوب تعهدات و سلب مسئولیت کارابازار در قبال استفاده کاربر ازخدمات، باشگاه مشتریان، مزایا، طرح‌های همکاری، شبکه معرفی، خرید از فروشگاه‌های طرف‌قراردادو هرگونه امکانات و تسهیلات مرتبط است که حسب مورد توسط کارابازار یا از طریق پلتفرم‌ها و شرکت‌هایهمکار در اختیار کاربر قرار می‌گیرد، بدون اینکه جزئیات مدل کسب‌وکار، شیوه تأمین منابع مالی،شبکه همکاری یا ساختار داخلی آن در این قرارداد ذکر یا افشا شود.</p>" +
"<p>موضوع این قرارداد، تعیین حدود و چارچوب تعهدات و سلب مسئولیت کارابازار در قبال استفاده کاربر ازخدمات، باشگاه مشتریان، مزایا، طرح‌های همکاری، سازمان معرفی، خرید از فروشگاه‌های طرف‌قراردادو هرگونه امکانات و تسهیلات مرتبط است که حسب مورد توسط کارابازار یا از طریق پلتفرم‌ها و شرکت‌هایهمکار در اختیار کاربر قرار می‌گیرد، بدون اینکه جزئیات مدل کسب‌وکار، شیوه تأمین منابع مالی،سازمان همکاری یا ساختار داخلی آن در این قرارداد ذکر یا افشا شود.</p>" +
"<p class='section-title'>ماده ۲ - اقرار و آگاهی کاربر</p><p>۲-۱. کاربر اقرار می‌نماید که کلیه اطلاعات کلی لازم در خصوص نحوه فعالیت، مزایا، ریسک‌های احتمالی," +
"سازوکار کلی استفاده از خدمات، و چارچوب کلی همکاری را از مراجع رسمی معرفی‌شده توسط کارابازاردریافت کرده، سؤالات خود را مطرح نموده و پاسخ مناسب را دریافت نموده است.</p><p>۲-۲. کاربر با امضای این قرارداد اقرار می‌نماید که:</p><ul><li>با اراده آزاد و بدون هرگونه اجبار، اکراه، فریب یا سوءاستفاده از ناآگاهی وارد این همکاری شده است.</li><li>از احتمال وجود ریسک‌های مالی، اعتباری، قانونی و شبکه‌ای مرتبط با فعالیت خود آگاه بوده و آن را می‌پذیرد.</li>" +
"<li>هرگونه تصمیم برای استفاده از تسهیلات، مزایا، خدمات باشگاه مشتریان، فعالیت در شبکه معرفی، خرید از فروشگاه‌ها و جذب افراد جدید، تصمیمی شخصی و آگاهانه است و به‌عنوان تعهد یا ضمانت بازده مشخص از سوی کارابازار تلقی نمی‌شود.</li></ul>" +
"<li>هرگونه تصمیم برای استفاده از تسهیلات، مزایا، خدمات باشگاه مشتریان، فعالیت در سازمان معرفی، خرید از فروشگاه‌ها و جذب افراد جدید، تصمیمی شخصی و آگاهانه است و به‌عنوان تعهد یا ضمانت بازده مشخص از سوی کارابازار تلقی نمی‌شود.</li></ul>" +
"<p class='section-title'>ماده ۳ - سلب مسئولیت کارابازار</p><p>۳-۱. کاربر صراحتاً می‌پذیرد که کارابازار صرفاً در چارچوب قوانین و مقررات حاکم و مطابق قراردادها و تفاهم‌نامه‌های خود با شرکای تجاری و پلتفرم‌های همکار، خدمات و امکانات مشخصی را در اختیار کاربر قرار می‌دهد و هیچ‌گونه تعهدی نسبت به:</p><ul><li>تحقق سود یا بازده مالی مشخص،</li><li>ثبات ارزش هرگونه اعتبار، امتیاز، یا منافع غیرنقدی که ممکن است در قالب طرح‌ها به کاربر تخصیص یابد،</li>" +
"<li>عملکرد اشخاص ثالث (از جمله پلتفرم‌ها، فروشگاه‌ها، شرکت‌های بیمه، ارائه‌دهندگان خدمات سلامت و هر شخص حقیقی یا حقوقی همکار)،</li><li>تصمیمات و اقدامات کاربر در جذب افراد جدید و ایجاد زیرمجموعه،</li></ul><p>نداشته و نخواهد داشت مگر در حدودی که به‌صراحت در قرارداد یا قوانین آمره بر عهده کارابازار گذاشته شده است.</p><p>" +
"۳-۲. هرگونه خسارت، دعوی، تهدید، مطالبه، ادعا، شکایت یا پیگیری حقوقی که ناشی از تصمیمات شخصی کاربر،نحوه استفاده وی از تسهیلات و مزایا، جذب دیگران، خرید کالا و خدمات، یا تعاملات او با اشخاص ثالث باشد،مستقیماً متوجه خود کاربر است و کارابازار نسبت به آن هیچ‌گونه مسئولیت مدنی، کیفری، مالی یا قراردادی ندارد," +
"مگر در مواردی که طبق قوانین آمره، مسئولیت صریح و غیرقابل‌اسقاط بر عهده کارابازار قرار گرفته باشد.</p><p>۳-۳. کاربر می‌پذیرد که هیچ‌گونه ادعایی مبنی بر فریب، اغفال، ارائه اطلاعات نادرست یا وعده‌های خارج ازچهارچوب رسمی از سوی کارابازار یا نمایندگان رسمی آن، پس از امضای این قرارداد، از وی مسموع نخواهد بود،مگر آنکه طبق رأی قطعی مرجع قضایی صالح، خلاف آن ثابت شود.</p><p class='section-title'>ماده ۴ - مسئولیت کاربر در قبال تهدید، ادعا و شکایت</p>" +
"<p>۴-۱. کاربر متعهد است در صورت طرح هرگونه تهدید، ادعا، مطالبه، شکایت یا پیگیری حقوقی از سوی اشخاص ثالث(از جمله افراد جذب‌شده توسط کاربر، زیرمجموعه‌ها، خریداران معرفی‌شده یا سایر اشخاص)، که ناشی از عملکردشخصی کاربر، معرفی‌های وی، شیوه تبلیغ، وعده‌های خارج از چارچوب رسمی کارابازار، یا نقض قوانین و مقرراتباشد، خود رأساً پاسخگو باشد و کارابازار را از هرگونه مسئولیت و هزینه احتمالی در این خصوص مصون بدارد.</p><p>۴-۲. کاربر می‌پذیرد که:</p><ul><li>هرگونه تهدید یا ادعای بی‌جا، غیرمنطقی، غیرمستند یا مغایر با مفاد این قرارداد، متوجه شخص کاربر است.</li>" +
"<li>در صورت وارد آمدن خسارت مادی یا معنوی به کارابازار ناشی از چنین تهدیدات یا ادعاهایی، کارابازار حق دارد نسبت به مطالبه خسارت از کاربر، طبق قوانین و از طریق مراجع صالح اقدام نماید.</li></ul>" +
"<p class='section-title'>ماده ۵ - محرمانگی و عدم افشای جزئیات کسب‌وکار</p><p>۵-۱. کاربر می‌پذیرد که جزئیات فنی، مالی، ساختار شبکه، شیوه توزیع مزایا، قراردادهای داخلی کارابازار باشرکای تجاری و پلتفرم‌های همکار، اطلاعات محرمانه تجاری محسوب شده و خارج از این قرارداد است و افشای آن‌هاتوسط کاربر بدون مجوز کتبی کارابازار، مجاز نمی‌باشد.</p><p>۵-۲. کاربر حق استناد به عدم درج جزئیات ساختار کسب‌وکار در این قرارداد را به‌عنوان دلیل بطلان، فسخ یاادعای فریب علیه کارابازار از خود سلب می‌نماید.</p>" +
"<p class='section-title'>ماده ۵ - محرمانگی و عدم افشای جزئیات کسب‌وکار</p><p>۵-۱. کاربر می‌پذیرد که جزئیات فنی، مالی، ساختار سازمان، شیوه توزیع مزایا، قراردادهای داخلی کارابازار باشرکای تجاری و پلتفرم‌های همکار، اطلاعات محرمانه تجاری محسوب شده و خارج از این قرارداد است و افشای آن‌هاتوسط کاربر بدون مجوز کتبی کارابازار، مجاز نمی‌باشد.</p><p>۵-۲. کاربر حق استناد به عدم درج جزئیات ساختار کسب‌وکار در این قرارداد را به‌عنوان دلیل بطلان، فسخ یاادعای فریب علیه کارابازار از خود سلب می‌نماید.</p>" +
"<p class='section-title'>ماده ۶ - کد تأیید یکبارمصرف (OTP) و شناسه قرارداد</p><p>۶-۱. به منظور تأیید هویت کاربر و جایگزین کردن امضا در محیط غیرحضوری، برای این قرارداد یک شناسه یکتا(GUID) تولید شده و از طریق سامانه کارابازار، کد تأیید یکبارمصرف (OTP) مرتبط با آن برای کاربر ارسالمی‌گردد.</p>" +
"<p>۶-۲. کاربر با وارد کردن صحیح کد تأیید (OTP) در سامانه، اقرار می‌نماید که:</p><ul><li>مالک شماره تماس ثبت‌شده در این قرارداد است یا با اجازه قانونی مالک از آن استفاده می‌کند،</li><li>از محتوای قرارداد به‌طور کامل آگاه است و آن را بدون ابهام و با رضایت کامل می‌پذیرد،</li><li>شناسه یکتا و OTP ارسال‌شده، معادل امضای الکترونیکی معتبر وی بر روی این قرارداد است.</li></ul>" +
"<p class='section-title'>ماده ۷ - مدت، اصلاح و حاکمیت قانون</p>" +
@@ -21,9 +21,11 @@
else if (_addresses.Count == 0)
{
<MudAlert Severity="Severity.Warning">
هیچ آدرسی ثبت نشده است. لطفاً از بخش پروفایل آدرس خود را اضافه کنید.
هیچ آدرسی ثبت نشده است. می‌توانید همین‌جا آدرس جدید اضافه کنید.
</MudAlert>
<MudButton Class="mt-2" Variant="Variant.Outlined" Href="/profile/addresses">افزودن آدرس</MudButton>
<MudButton Class="mt-2" Variant="Variant.Outlined" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="OpenAddAddressDialog">افزودن آدرس</MudButton>
}
else
{
@@ -40,13 +42,25 @@
<MudText Typo="Typo.subtitle2">@address.Title</MudText>
<MudText Typo="Typo.body2" Class="mud-text-secondary">@address.Address</MudText>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
@if (address.IsDefault)
{
<MudChip T="string" Color="Color.Success" Variant="Variant.Outlined">پیش‌فرض</MudChip>
<MudChip T="string" Color="Color.Success" Variant="Variant.Outlined" Size="Size.Small">پیش‌فرض</MudChip>
}
<span @onclick:stopPropagation="true">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Size="Size.Small"
Color="Color.Primary"
aria-label="ویرایش آدرس"
OnClick="@(() => OpenEditAddressDialog(address))" />
</span>
</MudStack>
</MudStack>
</MudPaper>
}
<MudButton Class="mt-2" Variant="Variant.Text" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="OpenAddAddressDialog">افزودن آدرس جدید</MudButton>
</MudStack>
}
</MudPaper>
@@ -1,5 +1,7 @@
using CMSMicroservice.Protobuf.Protos.UserAddress;
using CMSMicroservice.Protobuf.Protos.UserOrder;
using FrontOffice.Main.Pages.Profile.Components;
using FrontOffice.Main.Shared;
using FrontOffice.Main.Utilities;
using Microsoft.AspNetCore.Components;
using MudBlazor;
@@ -33,9 +35,12 @@ public partial class CheckoutSummary : ComponentBase
{
await AuthDialogService.ShowAuthDialogAsync();
}
// لود سبد خرید (فقط اگر کاربر لاگین کرده باشد)
await Cart.EnsureInitializedAsync();
var userInfo = await AuthService.GetUserAuthInfo();
if (userInfo.HasAddress)
await LoadAddresses();
else
_addresses = new();
await LoadWalletBalance();
}
@@ -43,7 +48,6 @@ public partial class CheckoutSummary : ComponentBase
{
if (firstRender)
{
// بارگذاری نرخ VAT
await VAT.LoadAsync();
}
await base.OnAfterRenderAsync(firstRender);
@@ -52,9 +56,7 @@ public partial class CheckoutSummary : ComponentBase
private async Task LoadWalletBalance()
{
var walletResult = await WalletService.GetBalancesAsync();
walletBalance = walletResult.CreditBalance
// + walletResult.NetworkBalance
;
walletBalance = walletResult.CreditBalance;
}
private async Task LoadAddresses()
@@ -71,12 +73,14 @@ public partial class CheckoutSummary : ComponentBase
else
{
_addresses = new();
_selectedAddress = null;
}
}
catch (Exception ex)
{
Snackbar.Add($"خطا در بارگذاری آدرس‌ها: {ex.Message}", Severity.Error);
_addresses = new();
_selectedAddress = null;
}
finally
{
@@ -85,6 +89,30 @@ public partial class CheckoutSummary : ComponentBase
}
}
private async Task OpenAddAddressDialog()
{
var dialog = await DialogService.ShowAsync<AddAddressDialog>("افزودن آدرس جدید");
var result = await dialog.Result;
if (result is { Canceled: false })
{
await AuthService.RefreshTokenAsync();
await LoadAddresses();
}
}
private async Task OpenEditAddressDialog(CustomerAddressModel address)
{
var dialog = await DialogService.ShowAsync<EditAddressDialog>("ویرایش آدرس", new DialogParameters<EditAddressDialog>
{
{ x => x.Model, address }
});
var result = await dialog.Result;
if (result is { Canceled: false })
{
await LoadAddresses();
}
}
private async Task PlaceOrder()
{
if (!CanPlaceOrder || _selectedAddress is null)
@@ -93,11 +121,15 @@ public partial class CheckoutSummary : ComponentBase
return;
}
var totalRequired = VAT.AddVAT(Cart.Total);
if (await TryHandleInsufficientBalanceAsync(totalRequired))
return;
try
{
var request = new SubmitShopBuyOrderRequest
{
TotalAmount = VAT.AddVAT(Cart.Total)
TotalAmount = totalRequired
};
var response = await UserOrderContract.SubmitShopBuyOrderAsync(request);
@@ -107,15 +139,85 @@ public partial class CheckoutSummary : ComponentBase
}
catch (Exception ex)
{
if (CreditChargeNavigation.IsInsufficientWalletBalance(ex))
{
await LoadWalletBalance();
await TryHandleInsufficientBalanceAsync(totalRequired);
return;
}
Snackbar.Add($"خطا در ثبت سفارش: {ex.Message}", Severity.Error);
}
}
private async Task<bool> TryHandleInsufficientBalanceAsync(long totalRequired)
{
if (walletBalance >= totalRequired)
return false;
var shortfall = totalRequired - walletBalance;
var allowCharge = false;
var hasPurchasedPackage = false;
var isMagicWallet = false;
var magicCeilingFull = false;
try
{
var userInfo = await AuthService.GetUserAuthInfo();
allowCharge = userInfo.IsClubMemberActive;
hasPurchasedPackage = userInfo.HasPurchasedPackage;
}
catch
{
allowCharge = false;
}
try
{
var magicStatus = await WalletService.GetMagicWalletStatusAsync();
isMagicWallet = magicStatus.WalletMode == 1;
magicCeilingFull = isMagicWallet && magicStatus.MagicRemainingDeposit <= 0;
}
catch
{
isMagicWallet = false;
magicCeilingFull = false;
}
var parameters = new DialogParameters<InsufficientCreditDialog>
{
{ x => x.CurrentBalance, walletBalance },
{ x => x.RequiredAmount, totalRequired },
{ x => x.ShortfallAmount, shortfall },
{ x => x.AllowChargeCredit, allowCharge },
{ x => x.HasPurchasedPackage, hasPurchasedPackage },
{ x => x.IsMagicWallet, isMagicWallet },
{ x => x.MagicCeilingFull, magicCeilingFull }
};
var dialog = await DialogService.ShowAsync<InsufficientCreditDialog>(
"موجودی کافی نیست",
parameters,
new DialogOptions
{
CloseOnEscapeKey = true,
MaxWidth = MaxWidth.Small,
FullWidth = true
});
var result = await dialog.Result;
if (allowCharge && result is { Canceled: false } && result.Data is long chargeShortfall)
{
var chargeAmount = CreditChargeNavigation.NormalizeChargeAmount(chargeShortfall);
Navigation.NavigateTo(CreditChargeNavigation.BuildChargeUrl(
chargeAmount,
RouteConstants.Store.CheckoutSummary));
}
return true;
}
private static string FormatPrice(long price) => string.Format("{0:N0} تومان", price);
/// <summary>
/// محاسبه مالیات بر ارزش افزوده
/// </summary>
private long CalculateVAT() => VAT.CalculateVAT(Cart.Total);
private static string GetProductImageUrl(string? imageUrl)
@@ -112,30 +112,6 @@ else
</MudStack>
}
</MudStack>
@* نمایش PV سفارش *@
@if (_pvData != null && _pvData.TotalPv > 0)
{
<MudDivider Class="my-2" />
<MudStack Spacing="1" Class="pa-2" Style="background-color: var(--mud-palette-background-grey); border-radius: 8px;">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Icon="@Icons.Material.Filled.TrendingUp" Color="Color.Info" Size="Size.Small" />
<MudText Typo="Typo.subtitle2" Color="Color.Info">امتیاز فروش (PV)</MudText>
</MudStack>
@foreach (var product in _pvData.Products)
{
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.caption" Class="mud-text-secondary">@product.ProductTitle × @product.Quantity</MudText>
<MudText Typo="Typo.caption">@string.Format("{0:N0}", product.TotalPv) PV</MudText>
</MudStack>
}
<MudDivider Class="my-1" />
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2" Class="fw-bold">جمع PV:</MudText>
<MudText Typo="Typo.body2" Color="Color.Info" Class="fw-bold">@string.Format("{0:N0}", _pvData.TotalPv) PV</MudText>
</MudStack>
</MudStack>
}
</MudStack>
</MudPaper>
</MudContainer>
@@ -12,17 +12,12 @@ public partial class OrderDetail : ComponentBase
[Parameter] public long id { get; set; }
private GetUserOrderResponse? _order;
private CalculateOrderPVResponse? _pvData;
private bool _loading;
protected override async Task OnParametersSetAsync()
{
_loading = true;
_order = await OrderService.GetOrderAsync(id);
if (_order != null)
{
_pvData = await OrderService.CalculateOrderPVAsync(id);
}
_loading = false;
}
@@ -52,4 +47,3 @@ public partial class OrderDetail : ComponentBase
private static string GetProductImageUrl(string? imageUrl)
=> string.IsNullOrWhiteSpace(imageUrl) ? "/images/product-placeholder.svg" : imageUrl.TrimStart('/');
}
@@ -47,11 +47,18 @@ public partial class OrderTracking : ComponentBase
{
if (_order is null) return;
// Build tracking steps based on PaymentStatus
var isPaid = _order.PaymentStatus == Messages.PaymentStatus.Success;
// Domain/public_messages: None=0, Pending=1, InTransit=2, Delivered=3, Returned=4, Cancelled=5, ReadyForOfficePickup=6
var delivery = (int)_order.DeliveryStatus;
var isCancelled = delivery == 5;
var isReturned = delivery == 4;
var isOfficePickup = delivery == 6;
var isInTransit = delivery == 2;
var isDelivered = delivery == 3;
var isPendingDelivery = delivery is 0 or 1;
_trackingSteps = new List<TrackingStep>
{
_trackingSteps =
[
new()
{
Title = "ثبت سفارش",
@@ -61,45 +68,77 @@ public partial class OrderTracking : ComponentBase
},
new()
{
Title = "در انتظار پرداخت",
Description = "منتظر تایید پرداخت هستیم",
Title = "پرداخت",
Description = isPaid ? "پرداخت شما تایید شد" : "منتظر تایید پرداخت هستیم",
IsCompleted = isPaid,
IsCurrent = !isPaid,
IsCurrent = !isPaid && !isCancelled,
Date = isPaid ? FormatDate(_order.PaymentDate) : ""
},
new()
}
];
if (isCancelled)
{
Title = "تایید پرداخت",
Description = "پرداخت شما تایید شد",
IsCompleted = isPaid,
IsCurrent = false,
Date = isPaid ? FormatDate(_order.PaymentDate) : ""
},
new()
_trackingSteps.Add(new()
{
Title = "در حال پردازش",
Description = "سفارش شما در حال آماده‌سازی است",
IsCompleted = false,
IsCurrent = isPaid,
Title = "لغو شده",
Description = "ارسال این سفارش لغو شده است",
IsCompleted = true,
IsCurrent = true,
Date = ""
},
new()
});
return;
}
if (isReturned)
{
_trackingSteps.Add(new()
{
Title = "مرجوع شده",
Description = "سفارش مرجوع شده است",
IsCompleted = true,
IsCurrent = true,
Date = ""
});
return;
}
if (isOfficePickup)
{
_trackingSteps.Add(new()
{
Title = "آماده تحویل در دفتر",
Description = "سفارش برای تحویل حضوری در دفتر آماده است",
IsCompleted = true,
IsCurrent = true,
Date = ""
});
return;
}
_trackingSteps.Add(new()
{
Title = "در حال آماده‌سازی",
Description = "سفارش شما در حال آماده‌سازی است",
IsCompleted = isInTransit || isDelivered,
IsCurrent = isPaid && isPendingDelivery,
Date = ""
});
_trackingSteps.Add(new()
{
Title = "ارسال شده",
Description = "سفارش شما به پست تحویل داده شد",
IsCompleted = false,
IsCurrent = false,
IsCompleted = isDelivered,
IsCurrent = isInTransit,
Date = ""
},
new()
});
_trackingSteps.Add(new()
{
Title = "تحویل داده شده",
Description = "سفارش به دست شما رسید",
IsCompleted = false,
IsCompleted = isDelivered,
IsCurrent = false,
Date = ""
}
};
});
}
private static string FormatDate(Google.Protobuf.WellKnownTypes.Timestamp? timestamp)
+57 -35
View File
@@ -1,75 +1,96 @@
@attribute [Route(RouteConstants.Store.Orders)]
@* Injection is handled in code-behind *@
<PageTitle>سفارشات من</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="py-6">
<MudStack Spacing="3">
<PageHeader Title="سفارشات من" BackHref="@RouteConstants.Store.Products" />
@if (_loading)
{
<LoadingState />
}
else if (_orders.Count == 0)
{
<MudAlert Severity="Severity.Info">هنوز سفارشی ثبت نکرده‌اید.</MudAlert>
<MudAlert Severity="Severity.Info">هنوز سفارشی ثبت نشده است.</MudAlert>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Store"
Href="@RouteConstants.Store.Products">
مشاهده فروشگاه
</MudButton>
}
else
{
<!-- Desktop Table -->
<MudHidden Breakpoint="Breakpoint.MdAndUp" Invert="true">
<MudTable Items="_orders" Dense="true">
<MudPaper Elevation="1" Class="pa-4 rounded-lg">
<MudTable Items="_orders">
<HeaderContent>
<MudTh>شناسه</MudTh>
<MudTh>شماره سفارش</MudTh>
<MudTh>تعداد اقلام</MudTh>
<MudTh>مبلغ کل</MudTh>
<MudTh>وضعیت پرداخت</MudTh>
<MudTh>وضعیت ارسال</MudTh>
<MudTh>تاریخ</MudTh>
<MudTh>وضعیت</MudTh>
<MudTh>مبلغ</MudTh>
<MudTh></MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Id</MudTd>
<MudTd>@GetItemsCount(context)</MudTd>
<MudTd>@FormatPrice(GetTotalAmount(context))</MudTd>
<MudTd>
@if (context.PaymentDate != null)
{
@context.PaymentDate.ToDateTime().MiladiToJalaliWithTime()
}
else
{
<text>در انتظار پرداخت</text>
}
<MudChip T="string" Size="Size.Small"
Color="@GetPaymentStatusColor(context.PaymentStatus)"
Variant="Variant.Filled">
@GetPaymentStatusText(context.PaymentStatus)
</MudChip>
</MudTd>
<MudTd>@GetStatusText(context.PaymentStatus)</MudTd>
<MudTd>@FormatPrice(context.FactorDetails.Sum(s=>(s.UnitPrice ?? 0) * (s.Count ?? 0)))</MudTd>
<MudTd>
<MudButton Variant="Variant.Text" Href="@($"{RouteConstants.Store.OrderDetail}{context.Id}")" StartIcon="@Icons.Material.Filled.Receipt">جزئیات</MudButton>
<MudChip T="string" Size="Size.Small"
Color="@GetDeliveryStatusColor((int)context.DeliveryStatus)"
Variant="Variant.Outlined">
@GetDeliveryStatusText((int)context.DeliveryStatus)
</MudChip>
</MudTd>
<MudTd>@FormatDate(context)</MudTd>
<MudTd>
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary"
OnClick="() => ViewOrder(context.Id)">
جزئیات
</MudButton>
</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
</MudHidden>
<!-- Mobile Cards -->
<MudHidden Breakpoint="Breakpoint.MdAndUp">
<MudStack Spacing="2">
@foreach (var o in _orders)
@foreach (var order in _orders)
{
<MudPaper Class="pa-3 rounded-lg" Outlined="true">
<MudPaper Class="pa-3 rounded-lg cursor-pointer" Outlined="true"
@onclick="() => ViewOrder(order.Id)">
<MudStack Spacing="1">
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText>سفارش #@o.Id</MudText>
<MudText Class="mud-text-secondary">
@if (o.PaymentDate != null)
{
@o.PaymentDate.ToDateTime().MiladiToJalaliWithTime()
}
else
{
<text>در انتظار پرداخت</text>
}
</MudText>
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.subtitle2">سفارش #@order.Id</MudText>
<MudChip T="string" Size="Size.Small"
Color="@GetPaymentStatusColor(order.PaymentStatus)"
Variant="Variant.Filled">
@GetPaymentStatusText(order.PaymentStatus)
</MudChip>
</MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText>وضعیت: @GetStatusText(o.PaymentStatus)</MudText>
<MudText Color="Color.Primary">@FormatPrice(o.FactorDetails.Sum(s=>(s.UnitPrice ?? 0) * (s.Count ?? 0)))</MudText>
<MudText Typo="Typo.caption" Class="mud-text-secondary">@FormatDate(order)</MudText>
<MudChip T="string" Size="Size.Small"
Color="@GetDeliveryStatusColor((int)order.DeliveryStatus)"
Variant="Variant.Outlined">
@GetDeliveryStatusText((int)order.DeliveryStatus)
</MudChip>
</MudStack>
<MudStack Row="true" Justify="Justify.FlexEnd">
<MudButton Size="Size.Small" Variant="Variant.Outlined" Href="@($"{RouteConstants.Store.OrderDetail}{o.Id}")" StartIcon="@Icons.Material.Filled.Receipt">جزئیات</MudButton>
<MudDivider />
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2">@GetItemsCount(order) قلم</MudText>
<MudText Typo="Typo.body2" Class="fw-semibold">@FormatPrice(GetTotalAmount(order)) تومان</MudText>
</MudStack>
</MudStack>
</MudPaper>
@@ -77,4 +98,5 @@
</MudStack>
</MudHidden>
}
</MudStack>
</MudContainer>
@@ -1,5 +1,5 @@
using System;
using CMSMicroservice.Protobuf.Protos.UserOrder;
using DateTimeConverterCL;
using FrontOffice.Main.Utilities;
using Microsoft.AspNetCore.Components;
using Messages = CMSMicroservice.Protobuf.Protos;
@@ -10,7 +10,6 @@ namespace FrontOffice.Main.Pages.Store;
public partial class Orders : ComponentBase
{
[Inject] private OrderService OrderService { get; set; } = default!;
[Inject] private VATService VAT { get; set; } = default!;
private List<GetUserOrderResponse> _orders = new();
private bool _loading;
@@ -33,28 +32,81 @@ public partial class Orders : ComponentBase
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
private void ViewOrder(long orderId)
{
if (firstRender)
{
// بارگذاری نرخ VAT
await VAT.LoadAsync();
}
await base.OnAfterRenderAsync(firstRender);
Navigation.NavigateTo($"{RouteConstants.Store.OrderDetail}{orderId}");
}
private static string FormatPrice(long price) => string.Format("{0:N0} تومان", price);
private static string FormatPrice(long price) => $"{price:N0}";
private string GetStatusText(Messages.PaymentStatus contextPaymentStatus)
private static string FormatDate(GetUserOrderResponse order)
{
return contextPaymentStatus switch
try
{
var timestamp = order.PaymentDate;
if (timestamp is null)
return "در انتظار پرداخت";
return timestamp.ToDateTime().ToLocalTime().MiladiToJalaliWithTime();
}
catch
{
return "—";
}
}
private static int GetItemsCount(GetUserOrderResponse order) =>
order.FactorDetails.Sum(f => f.Count ?? 0);
private static long GetTotalAmount(GetUserOrderResponse order)
{
if (order.VatInfo is not null && order.VatInfo.TotalAmount > 0)
return order.VatInfo.TotalAmount;
if (order.Amount > 0)
return order.Amount;
return order.FactorDetails.Sum(f => (f.UnitPrice ?? 0) * (f.Count ?? 0));
}
private static string GetPaymentStatusText(Messages.PaymentStatus status) => status switch
{
Messages.PaymentStatus.Pending => "در انتظار پرداخت",
Messages.PaymentStatus.Success => "پرداخت شده",
Messages.PaymentStatus.Reject => "پرداخت ناموفق",
_ => "نامشخص",
_ => "نامشخص"
};
private static Color GetPaymentStatusColor(Messages.PaymentStatus status) => status switch
{
Messages.PaymentStatus.Pending => Color.Warning,
Messages.PaymentStatus.Success => Color.Success,
Messages.PaymentStatus.Reject => Color.Error,
_ => Color.Default
};
// Domain/public_messages: None=0, Pending=1, InTransit=2, Delivered=3, Returned=4, Cancelled=5, ReadyForOfficePickup=6
private static string GetDeliveryStatusText(int status) => status switch
{
0 => "بدون ارسال",
1 => "در انتظار ارسال",
2 => "ارسال شده",
3 => "تحویل داده شده",
4 => "مرجوع شده",
5 => "لغو شده",
6 => "آماده تحویل در دفتر",
_ => "نامشخص"
};
private static Color GetDeliveryStatusColor(int status) => status switch
{
0 => Color.Default,
1 => Color.Warning,
2 => Color.Primary,
3 => Color.Success,
4 => Color.Error,
5 => Color.Dark,
6 => Color.Primary,
_ => Color.Default
};
}
}
@@ -21,6 +21,7 @@ public partial class ProductDetail : ComponentBase, IDisposable
private Product? _product;
private bool _loading;
private bool _initialized;
private int _qty = 1;
private const int MinQty = 1;
@@ -48,29 +49,35 @@ public partial class ProductDetail : ComponentBase, IDisposable
private bool IsInCart => CurrentCartItem is not null;
private int CurrentCartQuantity => CurrentCartItem?.Quantity ?? 0;
protected override async Task OnParametersSetAsync()
{
}
protected override async Task OnInitializedAsync()
{
_isAuthenticated = await AuthService.IsAuthenticatedAsync();
await Cart.EnsureInitializedAsync();
Cart.OnChange += HandleCartChanged;
_initialized = true;
}
protected override async Task OnParametersSetAsync()
{
if (!_initialized || id <= 0)
return;
await LoadProductAsync();
}
private async Task LoadProductAsync()
{
_loading = true;
_product = await ProductService.GetByIdAsync(id);
_loading = false;
if (_product is not null)
{
_galleryItems = BuildGalleryItems(_product);
_selectedGalleryImage = _galleryItems.FirstOrDefault();
_categoryPaths = _product.Categories;
UpdateBreadcrumb();
_qty = Math.Clamp(CurrentCartItem?.Quantity ?? _qty, MinQty, MaxQty);
_qty = Math.Clamp(CurrentCartItem?.Quantity ?? MinQty, MinQty, Math.Max(MinQty, MaxQty));
}
else
{
@@ -78,19 +85,16 @@ public partial class ProductDetail : ComponentBase, IDisposable
_categoryPaths = Array.Empty<ProductCategoryPathInfo>();
_breadcrumbItems.Clear();
}
StateHasChanged();
await base.OnInitializedAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (firstRender)
{
// بارگذاری نرخ VAT
await VAT.LoadAsync();
}
await base.OnAfterRenderAsync(firstRender);
}
private async Task AddToCart()
@@ -118,7 +118,8 @@
@foreach (var p in _products)
{
<MudItem xs="6" sm="6" md="3"
onclick="@(() => Navigation.NavigateTo($"{RouteConstants.Store.ProductDetail}{p.Id}"))">
onclick="@(() => OpenProduct(p.Id))">
<div id="@($"shop-product-{p.Id}")" class="h-100">
<MudCard Class="rounded-lg h-100 d-flex flex-column overflow-hidden"
Style="cursor:pointer;">
@@ -164,6 +165,7 @@
</MudButton>
</MudCardActions>
</MudCard>
</div>
</MudItem>
}
</MudGrid>
@@ -2,19 +2,11 @@ using System.Linq;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.JSInterop;
using FrontOffice.Main.Utilities;
namespace FrontOffice.Main.Pages.Store;
public enum ProductSortOption
{
PriceDesc, // گران‌ترین (پیش‌فرض)
PriceAsc, // ارزان‌ترین
Newest, // جدیدترین
Title // الفبایی
}
public partial class Products : ComponentBase, IDisposable
{
[Inject] private ProductService ProductService { get; set; } = default!;
@@ -23,6 +15,7 @@ public partial class Products : ComponentBase, IDisposable
[Inject] private VATService VAT { get; set; } = default!;
[Inject] private GuestActionGate GuestGate { get; set; } = default!;
[Inject] private AuthService AuthService { get; set; } = default!;
[Inject] private IJSRuntime Js { get; set; } = default!;
private bool _isAuthenticated;
private string _query = string.Empty;
@@ -35,8 +28,9 @@ public partial class Products : ComponentBase, IDisposable
private List<Product> _products = new();
private long? _activeCategoryId;
private string? _activeCategoryTitle;
private ProductSortOption _sortOption = ProductSortOption.PriceDesc; // پیش‌فرض: گران‌ترین
private ProductSortOption _sortOption = ProductSortOption.PriceDesc;
private bool _ignoreNextLocationChange;
private bool _pendingScrollRestore;
protected override async Task OnInitializedAsync()
{
@@ -44,36 +38,98 @@ public partial class Products : ComponentBase, IDisposable
await Cart.EnsureInitializedAsync();
Cart.OnChange += StateHasChanged;
Navigation.LocationChanged += HandleLocationChanged;
await LoadInitial();
ApplyStateFromUri();
await LoadPages(_currentPage);
_pendingScrollRestore = true;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
// بارگذاری نرخ VAT
await VAT.LoadAsync();
if (_pendingScrollRestore && !_loading && _products.Count > 0)
{
_pendingScrollRestore = false;
var payload = await ShopListScrollRestore.TakeAsync(Js, ShopListScrollRestore.StoreKey);
if (payload is not null)
await ShopListScrollRestore.RestoreAsync(Js, payload);
}
await base.OnAfterRenderAsync(firstRender);
}
private async Task LoadInitial()
private void ApplyStateFromUri()
{
var state = ShopListQueryState.Parse(Navigation.ToAbsoluteUri(Navigation.Uri));
_query = state.Query;
_sortOption = ShopListQueryState.ParseSortOption(state.Sort);
_activeCategoryId = state.CategoryId;
_currentPage = state.Pages;
}
private ShopListQueryState CaptureState() => new()
{
Query = _query,
Sort = ShopListQueryState.ToSortKey(_sortOption),
CategoryId = _activeCategoryId,
Pages = Math.Max(1, _currentPage)
};
private void SyncUrl()
{
var target = CaptureState().ToRelativeUrl(RouteConstants.Store.Products);
var currentPathAndQuery = Navigation.ToAbsoluteUri(Navigation.Uri).PathAndQuery;
if (string.Equals(currentPathAndQuery, target, StringComparison.OrdinalIgnoreCase)
|| string.Equals(currentPathAndQuery, target + "/", StringComparison.OrdinalIgnoreCase))
return;
_ignoreNextLocationChange = true;
Navigation.NavigateTo(target, replace: true);
}
private async Task LoadPages(int pagesToLoad)
{
_loading = true;
_currentPage = 1;
_products.Clear();
UpdateCategoryFilterFromUri();
var sortBy = GetSortByValue();
var result = await ProductService.GetProductsPagedAsync(_query, _activeCategoryId, sortBy, _currentPage, PageSize);
_hasMore = true;
pagesToLoad = Math.Max(1, pagesToLoad);
var sortBy = ShopListQueryState.ToApiSortBy(_sortOption);
for (var page = 1; page <= pagesToLoad; page++)
{
var result = await ProductService.GetProductsPagedAsync(
_query, _activeCategoryId, sortBy, page, PageSize);
if (page == 1)
{
_products = result.Products;
_hasMore = result.HasNext;
_totalCount = result.TotalCount;
}
else
{
_products.AddRange(result.Products);
}
_currentPage = page;
_hasMore = result.HasNext;
if (!_hasMore)
break;
}
_activeCategoryTitle = _activeCategoryId is { } categoryId
? (await CategoryService.GetByIdAsync(categoryId))?.Title
: null;
_loading = false;
}
private async Task ReloadFromFilters()
{
_currentPage = 1;
await LoadPages(1);
SyncUrl();
StateHasChanged();
}
private async Task LoadMore()
{
if (_loadingMore || !_hasMore) return;
@@ -82,42 +138,32 @@ public partial class Products : ComponentBase, IDisposable
StateHasChanged();
_currentPage++;
var sortBy = GetSortByValue();
var result = await ProductService.GetProductsPagedAsync(_query, _activeCategoryId, sortBy, _currentPage, PageSize);
var sortBy = ShopListQueryState.ToApiSortBy(_sortOption);
var result = await ProductService.GetProductsPagedAsync(
_query, _activeCategoryId, sortBy, _currentPage, PageSize);
_products.AddRange(result.Products);
_hasMore = result.HasNext;
SyncUrl();
_loadingMore = false;
StateHasChanged();
}
private string GetSortByValue()
{
return _sortOption switch
{
ProductSortOption.PriceDesc => "price desc",
ProductSortOption.PriceAsc => "price asc",
ProductSortOption.Newest => "id desc",
ProductSortOption.Title => "title asc",
_ => "price desc"
};
}
private async Task OnSortChanged() => await ReloadFromFilters();
private async Task OnSortChanged()
{
await LoadInitial();
}
private async Task OnQueryChanged(KeyboardEventArgs _)
{
await LoadInitial();
}
private async Task OnQueryChanged(KeyboardEventArgs _) => await ReloadFromFilters();
private async Task AddToCart(Product p)
{
await GuestGate.RunAsync(() => Cart.Add(p, 1));
}
private async Task OpenProduct(long productId)
{
await ShopListScrollRestore.SaveAsync(Js, ShopListScrollRestore.StoreKey, productId);
Navigation.NavigateTo($"{RouteConstants.Store.ProductDetail}{productId}");
}
private string FormatPrice(long price) => $"{VAT.AddVAT(price):N0} تومان";
public void Dispose()
@@ -128,24 +174,33 @@ public partial class Products : ComponentBase, IDisposable
private void HandleLocationChanged(object? sender, LocationChangedEventArgs args)
{
_ = InvokeAsync(LoadInitial);
}
private void UpdateCategoryFilterFromUri()
if (_ignoreNextLocationChange)
{
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
if (QueryHelpers.ParseQuery(uri.Query).TryGetValue("category", out var values) &&
long.TryParse(values.FirstOrDefault(), out var categoryId))
{
_activeCategoryId = categoryId;
_ignoreNextLocationChange = false;
return;
}
_activeCategoryId = null;
var uri = Navigation.ToAbsoluteUri(args.Location);
if (!uri.AbsolutePath.Equals(RouteConstants.Store.Products, StringComparison.OrdinalIgnoreCase))
return;
var incoming = ShopListQueryState.Parse(uri);
if (incoming.Matches(CaptureState()))
return;
_ = InvokeAsync(async () =>
{
ApplyStateFromUri();
await LoadPages(_currentPage);
_pendingScrollRestore = true;
StateHasChanged();
});
}
private void ClearCategoryFilter()
private async Task ClearCategoryFilter()
{
Navigation.NavigateTo(RouteConstants.Store.Products);
_activeCategoryId = null;
_activeCategoryTitle = null;
await ReloadFromFilters();
}
}
+39
View File
@@ -1,4 +1,5 @@
@page "/"
@model FrontOffice.Main.Pages.HostModel
@using Microsoft.AspNetCore.Components.Web
@namespace FrontOffice.Main.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@@ -8,6 +9,26 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@Model.Seo.Title</title>
<meta name="description" content="@Model.Seo.Description" />
<link rel="canonical" href="@Model.Seo.CanonicalUrl" />
@if (Model.Seo.NoIndex)
{
<meta name="robots" content="noindex, nofollow" />
}
else
{
<meta name="robots" content="index, follow" />
}
<meta property="og:type" content="website" />
<meta property="og:locale" content="fa_IR" />
<meta property="og:title" content="@Model.Seo.Title" />
<meta property="og:description" content="@Model.Seo.Description" />
<meta property="og:url" content="@Model.Seo.CanonicalUrl" />
<meta property="og:site_name" content="کارابازار سلامت" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="@Model.Seo.Title" />
<meta name="twitter:description" content="@Model.Seo.Description" />
<meta name="theme-color" content="#7c4dff" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
@@ -23,8 +44,26 @@
<link href="FrontOffice.Main.styles.css" rel="stylesheet" />
<!-- d3-org-chart custom styles -->
<link href="css/org-chart.css" rel="stylesheet" asp-append-version="true" />
<style>
.seo-crawlable {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>
</head>
<body>
@if (!string.IsNullOrWhiteSpace(Model.Seo.CrawlableContent))
{
<div class="seo-crawlable">@Model.Seo.CrawlableContent</div>
}
<component type="typeof(App)" render-mode="Server" />
<div id="blazor-error-ui">
@@ -0,0 +1,21 @@
using FrontOffice.Main.Utilities.Seo;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace FrontOffice.Main.Pages;
public class HostModel : PageModel
{
private readonly SeoMetadataProvider _seoMetadataProvider;
public SeoPageMetadata Seo { get; private set; } = default!;
public HostModel(SeoMetadataProvider seoMetadataProvider)
{
_seoMetadataProvider = seoMetadataProvider;
}
public async Task OnGetAsync()
{
Seo = await _seoMetadataProvider.GetForPathAsync(Request.Path);
}
}
+13
View File
@@ -1,5 +1,6 @@
using FluentValidation;
using FrontOffice.Main.Utilities;
using FrontOffice.Main.Utilities.Seo;
using System.Net;
using FrontOffice.Main.Utilities.Pdf;
@@ -12,6 +13,7 @@ builder.Services.AddServerSideBlazor();
#region AddCommonServices
builder.Services.AddCommonServices();
builder.Services.Configure<SeoSettings>(builder.Configuration.GetSection(SeoSettings.SectionName));
builder.Services.AddSingleton<MainService>();
#endregion
@@ -55,6 +57,17 @@ webApp.UseStaticFiles();
webApp.UseRouting();
webApp.MapBlazorHub();
webApp.MapGet("/sitemap.xml", async (SitemapGenerator sitemapGenerator, CancellationToken cancellationToken) =>
{
var bytes = await sitemapGenerator.GenerateBytesAsync(cancellationToken);
return Results.File(bytes, "application/xml; charset=utf-8");
});
// Legacy URLs from old site / common e-commerce paths — redirect to homepage
webApp.MapGet("/wishlist", () => Results.Redirect("/", permanent: true));
webApp.MapGet("/wishlist/", () => Results.Redirect("/", permanent: true));
webApp.MapFallbackToPage("/_Host");
+6 -2
View File
@@ -24,7 +24,9 @@
OnChangePhone="ChangePhoneAsync"
OnResendOtp="ResendOtpAsync"
ReferralCode="@_referralCode"
ReferralCodeChanged="OnReferralCodeChanged" />
ReferralCodeChanged="OnReferralCodeChanged"
InviterMobile="@_inviterMobile"
InviterMobileChanged="OnInviterMobileChanged" />
</div>
}
else
@@ -55,7 +57,9 @@ else
OnChangePhone="ChangePhoneAsync"
OnResendOtp="ResendOtpAsync"
ReferralCode="@_referralCode"
ReferralCodeChanged="OnReferralCodeChanged" />
ReferralCodeChanged="OnReferralCodeChanged"
InviterMobile="@_inviterMobile"
InviterMobileChanged="OnInviterMobileChanged" />
<MudStack Class="mt-4" Spacing="2">
@if (_currentStep == AuthStep.Phone)
@@ -44,6 +44,8 @@ public partial class AuthDialog : IDisposable
// Referral code field
private string? _referralCode;
// Inviter mobile (دعوت‌کننده)
private string? _inviterMobile;
[Inject] private ILocalStorageService LocalStorage { get; set; } = default!;
[Inject] private UserContract.UserContractClient UserClient { get; set; } = default!;
@@ -71,6 +73,7 @@ public partial class AuthDialog : IDisposable
}
_referralCode = await LocalStorage.GetItemAsync<string>("referral:code");
_inviterMobile = await LocalStorage.GetItemAsync<string>("inviter:mobile");
// await LocalStorage.RemoveItemAsync(TokenStorageKey);
}
@@ -83,6 +86,15 @@ public partial class AuthDialog : IDisposable
await LocalStorage.RemoveItemAsync("referral:code");
}
private async Task OnInviterMobileChanged(string? value)
{
_inviterMobile = value;
if (!string.IsNullOrWhiteSpace(value))
await LocalStorage.SetItemAsync("inviter:mobile", value);
else
await LocalStorage.RemoveItemAsync("inviter:mobile");
}
private void GenerateCaptcha()
{
_captchaCode = Guid.NewGuid().ToString("N")[..6].ToUpperInvariant();
@@ -204,6 +216,15 @@ public partial class AuthDialog : IDisposable
_verifyRequest.ParentReferralCode = effectiveReferralCode;
}
var storedInviterMobile = await LocalStorage.GetItemAsync<string>("inviter:mobile");
var effectiveInviterMobile = !string.IsNullOrWhiteSpace(_inviterMobile)
? _inviterMobile
: storedInviterMobile;
if (!string.IsNullOrWhiteSpace(effectiveInviterMobile))
{
_verifyRequest.InviterMobile = effectiveInviterMobile;
}
var validationResult = true; // _verifyRequestValidator.Validate(_verifyRequest);
if (!validationResult)
{
@@ -236,6 +257,7 @@ public partial class AuthDialog : IDisposable
await LocalStorage.RemoveItemAsync(PhoneStorageKey);
await LocalStorage.RemoveItemAsync(RedirectStorageKey);
await LocalStorage.RemoveItemAsync("referral:code");
await LocalStorage.RemoveItemAsync("inviter:mobile");
_attemptsLeft = MaxVerificationAttempts;
_verifyRequest.Code = string.Empty;
@@ -0,0 +1,152 @@
@using FrontOffice.Main.Utilities
<MudDialog>
<TitleContent>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.AccountBalanceWallet" Color="Color.Warning" />
<MudText Typo="Typo.h6">موجودی کیف پول اصلی کافی نیست</MudText>
</MudStack>
</TitleContent>
<DialogContent>
<MudStack Spacing="2">
@if (AllowChargeCredit)
{
@if (IsMagicWallet && !MagicCeilingFull)
{
<MudText Typo="Typo.body2" Class="mud-text-secondary">
برای تکمیل این سفارش موجودی شما کافی نیست. کیف پول شما در حالت جادویی است؛ برای دریافت ضریب، از مسیر شارژ جادویی اقدام کنید.
</MudText>
}
else if (IsMagicWallet && MagicCeilingFull)
{
<MudAlert Severity="Severity.Warning" Dense="true" Variant="Variant.Outlined">
سقف شارژ جادویی شما در این دور پر است. می‌توانید به‌صورت استثنایی کیف اصلی را بدون ضریب (۱:۱) شارژ کنید.
</MudAlert>
}
else
{
<MudText Typo="Typo.body2" Class="mud-text-secondary">
برای تکمیل این سفارش، موجودی اصلی شما کافی نیست. می‌توانید کیف پول را شارژ کنید و سپس سفارش را ثبت کنید.
</MudText>
}
}
else
{
<MudAlert Severity="Severity.Warning" Dense="true" Variant="Variant.Outlined">
برای شارژ کیف پول اصلی ابتدا باید پکیج را خریداری کرده و قرارداد باشگاه مشتریان را امضا کنید.
</MudAlert>
}
<MudPaper Outlined="true" Class="pa-3 rounded-lg">
<MudStack Spacing="1">
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2">موجودی فعلی:</MudText>
<MudText Typo="Typo.body2"><strong>@FormatPrice(CurrentBalance)</strong></MudText>
</MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2">مبلغ سفارش:</MudText>
<MudText Typo="Typo.body2"><strong>@FormatPrice(RequiredAmount)</strong></MudText>
</MudStack>
<MudDivider Class="my-1" />
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2" Color="Color.Error">کمبود:</MudText>
<MudText Typo="Typo.body2" Color="Color.Error"><strong>@FormatPrice(ShortfallAmount)</strong></MudText>
</MudStack>
</MudStack>
</MudPaper>
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">بستن</MudButton>
@if (AllowChargeCredit)
{
<MudButton Variant="Variant.Filled"
Color="@(IsMagicWallet && !MagicCeilingFull ? Color.Secondary : Color.Primary)"
StartIcon="@(IsMagicWallet && !MagicCeilingFull ? Icons.Material.Filled.AutoAwesome : Icons.Material.Filled.Payment)"
OnClick="GoToCharge">
@ChargeButtonLabel
</MudButton>
}
else if (!HasPurchasedPackage)
{
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.CardGiftcard"
OnClick="GoToPackages">
مشاهده پکیج‌ها
</MudButton>
}
else
{
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Handshake"
OnClick="GoToClubMembership">
امضای قرارداد باشگاه
</MudButton>
}
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; } = default!;
[Parameter]
public long CurrentBalance { get; set; }
[Parameter]
public long RequiredAmount { get; set; }
[Parameter]
public long ShortfallAmount { get; set; }
/// <summary>اگر false باشد، به صفحه شارژ هدایت نمی‌شود.</summary>
[Parameter]
public bool AllowChargeCredit { get; set; } = true;
[Parameter]
public bool HasPurchasedPackage { get; set; }
[Parameter]
public bool IsMagicWallet { get; set; }
/// <summary>سقف واریز جادویی این دور پر شده است.</summary>
[Parameter]
public bool MagicCeilingFull { get; set; }
private string ChargeButtonLabel =>
IsMagicWallet && !MagicCeilingFull
? "شارژ کیف جادویی"
: IsMagicWallet && MagicCeilingFull
? "شارژ عادی بدون ضریب (سقف جادویی پر است)"
: "شارژ حساب اصلی";
private void Cancel() => MudDialog.Cancel();
private void GoToCharge()
{
if (IsMagicWallet && !MagicCeilingFull)
{
MudDialog.Cancel();
Navigation.NavigateTo(RouteConstants.Profile.MagicWallet);
return;
}
MudDialog.Close(DialogResult.Ok(ShortfallAmount));
}
private void GoToPackages()
{
MudDialog.Cancel();
Navigation.NavigateTo(RouteConstants.Package.List);
}
private void GoToClubMembership()
{
MudDialog.Cancel();
Navigation.NavigateTo(RouteConstants.Club.Membership);
}
private static string FormatPrice(long price) => string.Format("{0:N0} تومان", price);
}
@@ -52,7 +52,7 @@
روش پرداخت خود را انتخاب کنید:
</MudText>
@* Direct Payment — always available *@
@* Direct Payment (ZarinPal) — always available *@
<MudPaper Elevation="0" Class="pa-4 rounded-lg pkg-payment-option"
Style="border:2px solid var(--mud-palette-primary); cursor:pointer;"
@onclick="() => SelectPaymentMethod(PaymentMethodType.DirectPayment)">
@@ -61,13 +61,29 @@
<MudIcon Icon="@Icons.Material.Filled.CreditCard" Color="Color.Primary" Size="Size.Medium" />
<MudStack Spacing="0" Class="flex-grow-1">
<MudText Typo="Typo.subtitle1" Color="Color.Primary"><b>پرداخت مستقیم</b></MudText>
<MudText Typo="Typo.caption" Class="mud-text-secondary">پرداخت آنی از طریق درگاه بانکی</MudText>
<MudText Typo="Typo.caption" Class="mud-text-secondary">پرداخت آنی از طریق درگاه بانکی (زرین‌پال)</MudText>
</MudStack>
<MudIcon Icon="@Icons.Material.Filled.ArrowBack" Color="Color.Primary" Size="Size.Small" />
</MudStack>
</MudStack>
</MudPaper>
@* TwentyCard IPG *@
<MudPaper Elevation="0" Class="pa-4 rounded-lg pkg-payment-option"
Style="border:2px solid var(--mud-palette-info); cursor:pointer;"
@onclick="() => SelectPaymentMethod(PaymentMethodType.TwentyCard)">
<MudStack Spacing="2">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.AccountBalance" Color="Color.Info" Size="Size.Medium" />
<MudStack Spacing="0" Class="flex-grow-1">
<MudText Typo="Typo.subtitle1" Color="Color.Info"><b>پرداخت با ۲۰کارت</b></MudText>
<MudText Typo="Typo.caption" Class="mud-text-secondary">پرداخت از طریق درگاه ۲۰کارت (نیاز به تأمین اعتبار)</MudText>
</MudStack>
<MudIcon Icon="@Icons.Material.Filled.ArrowBack" Color="Color.Info" Size="Size.Small" />
</MudStack>
</MudStack>
</MudPaper>
@* Daya Loan — only for base package AND first purchase cycle *@
@if (_selectedPackage.SupportsDayaPurchase && PurchaseCycleCount == 0)
{
@@ -229,6 +245,6 @@
: "background:rgba(16,185,129,.12); color:#10b981;";
}
public enum PaymentMethodType { DirectPayment, DayaLoan }
public enum PaymentMethodType { DirectPayment, TwentyCard, DayaLoan }
public record PackagePurchaseResult(PackageDto Package, PaymentMethodType Method);
}
@@ -38,11 +38,21 @@
<MudTextField Value="ReferralCode"
ValueChanged="@((string? v) => ReferralCodeChanged.InvokeAsync(v))"
Label="کد معرف (اختیاری)"
Label="کد معرف سازمان (اختیاری)"
Variant="Variant.Outlined"
Immediate="true"
Placeholder="کد معرف را وارد کنید"
Class="mb-3" />
Placeholder="کد معرف ۱۰ رقمی"
Class="mb-3"
HelperText="جایگاه شما در سازمان — با لینک معرف پر می‌شود" />
<MudTextField Value="InviterMobile"
ValueChanged="@((string? v) => InviterMobileChanged.InvokeAsync(v))"
Label="شماره موبایل دعوت‌کننده (اختیاری)"
Variant="Variant.Outlined"
Immediate="true"
Placeholder="مثلاً 0912xxxxxxx"
Class="mb-3"
HelperText="کسی که شما را به کارابازار معرفی کرده — متفاوت از معرف سازمان" />
@if (!string.IsNullOrWhiteSpace(ErrorMessage))
{
@@ -33,6 +33,10 @@ public partial class PhoneVerifyForm
[Parameter] public string? ReferralCode { get; set; }
[Parameter] public EventCallback<string?> ReferralCodeChanged { get; set; }
// ── Inviter (دعوت‌کننده) ──
[Parameter] public string? InviterMobile { get; set; }
[Parameter] public EventCallback<string?> InviterMobileChanged { get; set; }
// ── Verify actions ──
[Parameter] public EventCallback OnChangePhone { get; set; }
[Parameter] public EventCallback OnResendOtp { get; set; }
@@ -0,0 +1,60 @@
@inject IConfiguration Configuration
@inject IJSRuntime JSRuntime
<MudDialog>
<TitleContent>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.AccountBalance" Color="Color.Primary" />
<MudText Typo="Typo.h6">تأمین اعتبار ۲۰کارت</MudText>
</MudStack>
</TitleContent>
<DialogContent>
<MudStack Spacing="3">
<MudText Typo="Typo.body2" Class="mud-text-secondary">
برای پرداخت از طریق درگاه ۲۰کارت، ابتدا باید اعتبار حساب ۲۰کارت خود را تأمین کنید.
پس از تأمین اعتبار، روی «ادامه پرداخت» بزنید تا به درگاه هدایت شوید.
</MudText>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined">
اگر قبلاً اعتبار را تأمین کرده‌اید، می‌توانید مستقیم ادامه دهید.
</MudAlert>
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel" Variant="Variant.Text">انصراف</MudButton>
<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.OpenInNew"
OnClick="OpenCreditPortal"
Disabled="@string.IsNullOrWhiteSpace(_creditPortalUrl)">
تأمین اعتبار
</MudButton>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Payment"
OnClick="ContinuePayment">
ادامه پرداخت
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = default!;
private string _creditPortalUrl = string.Empty;
protected override void OnInitialized()
{
_creditPortalUrl = Configuration["TwentyCard:CreditPortalUrl"] ?? string.Empty;
}
private async Task OpenCreditPortal()
{
if (string.IsNullOrWhiteSpace(_creditPortalUrl)) return;
await JSRuntime.InvokeVoidAsync("open", _creditPortalUrl, "_blank");
}
private void ContinuePayment() => MudDialog.Close(DialogResult.Ok(true));
private void Cancel() => MudDialog.Cancel();
}
@@ -79,6 +79,7 @@ public class AuthService
var isSignMainContractStr = claims.FirstOrDefault(c => c.key == "IsSignMainContract").value;
var hasPurchasedPackageStr = claims.FirstOrDefault(c => c.key == "HasPurchasedPackage").value;
var isClubMemberActiveStr = claims.FirstOrDefault(c => c.key == "IsClubMemberActive").value;
var hasAddressStr = claims.FirstOrDefault(c => c.key == "HasAddress").value;
_userAuthInfo.UserId = long.TryParse(userIdStr, out var userId) ? userId : 0;
_userAuthInfo.FirstName = firstName ?? string.Empty;
@@ -88,6 +89,7 @@ public class AuthService
_userAuthInfo.IsSignMainContract = bool.TryParse(isSignMainContractStr, out var isSignMainContract) && isSignMainContract;
_userAuthInfo.HasPurchasedPackage = bool.TryParse(hasPurchasedPackageStr, out var hasPurchased) && hasPurchased;
_userAuthInfo.IsClubMemberActive = bool.TryParse(isClubMemberActiveStr, out var isClubActive) && isClubActive;
_userAuthInfo.HasAddress = bool.TryParse(hasAddressStr, out var hasAddress) && hasAddress;
}
private async Task<List<(string key, string value)>?> GetTokenClaimsAsync()
@@ -0,0 +1,50 @@
using Grpc.Core;
using Microsoft.JSInterop;
namespace FrontOffice.Main.Utilities;
public static class CreditChargeNavigation
{
public const long MinChargeAmount = 10_000;
public const string ReturnUrlStorageKey = "credit_charge_return_url";
private const string InsufficientBalanceMarker = "موجودی کیف پول کافی نیست";
public static long NormalizeChargeAmount(long shortfall) =>
Math.Max(shortfall, MinChargeAmount);
public static string BuildChargeUrl(long amount, string? returnUrl)
{
var url = $"{RouteConstants.Profile.ChargeCreditWallet}?amount={amount}";
if (IsValidReturnUrl(returnUrl))
url += $"&returnUrl={Uri.EscapeDataString(returnUrl!)}";
return url;
}
public static bool IsValidReturnUrl(string? url) =>
!string.IsNullOrWhiteSpace(url)
&& url.StartsWith('/')
&& !url.StartsWith("//", StringComparison.Ordinal);
public static bool IsInsufficientWalletBalanceMessage(string? message) =>
message?.Contains(InsufficientBalanceMarker, StringComparison.Ordinal) == true;
public static bool IsInsufficientWalletBalance(Exception ex) =>
ex switch
{
RpcException rpc => IsInsufficientWalletBalanceMessage(rpc.Status.Detail)
|| IsInsufficientWalletBalanceMessage(rpc.Message),
_ => IsInsufficientWalletBalanceMessage(ex.Message)
};
public static async Task SaveReturnUrlAsync(IJSRuntime js, string returnUrl)
{
if (!IsValidReturnUrl(returnUrl)) return;
await js.InvokeVoidAsync("sessionStorage.setItem", ReturnUrlStorageKey, returnUrl);
}
public static async Task<string?> GetReturnUrlAsync(IJSRuntime js) =>
await js.InvokeAsync<string?>("sessionStorage.getItem", ReturnUrlStorageKey);
public static async Task ClearReturnUrlAsync(IJSRuntime js) =>
await js.InvokeVoidAsync("sessionStorage.removeItem", ReturnUrlStorageKey);
}
@@ -219,6 +219,8 @@ public class DiscountOrderService
2 => "ارسال شده",
3 => "تحویل داده شده",
4 => "لغو شده",
5 => "آماده تحویل در دفتر",
6 => "مرجوع شده",
_ => "نامشخص"
};
@@ -228,7 +230,9 @@ public class DiscountOrderService
1 => Color.Info,
2 => Color.Primary,
3 => Color.Success,
4 => Color.Error,
4 => Color.Dark,
5 => Color.Primary,
6 => Color.Error,
_ => Color.Default
};
@@ -127,8 +127,8 @@ public class DiscountProductService
}
}
public Task<DiscountProductListResult> GetTopSellingAsync(int count = 6)
=> GetProductsAsync(page: 1, pageSize: count, sortBy: "SaleCount desc");
public Task<DiscountProductListResult> GetTopSellingAsync(int count = 6, bool? inStock = null)
=> GetProductsAsync(page: 1, pageSize: count, sortBy: "SaleCount desc", inStock: inStock);
public async Task<DiscountProductDetail?> GetByIdAsync(long productId)
{
@@ -14,13 +14,30 @@ public class NetworkNodeDto
public NetworkNodeDto? RightChild { get; set; }
public int Level { get; set; }
public bool IsActive { get; set; } = true;
public DateTime? JoinedAt { get; set; }
public DateTime? JoinedAt { get; set; } // تاریخ ثبت‌نام (UserCreated)
public bool IsClubActive { get; set; }
public string? ActivationWeekNumber { get; set; }
/// <summary>
/// کد معرف کاربر
/// </summary>
public string? ReferralCode { get; set; }
/// <summary>
/// نام پکیج موثر (مثلاً پکیج طلایی / نقره‌ای)
/// </summary>
public string? PackageName { get; set; }
/// <summary>تعداد اعضای فعال با پکیج طلایی جاری در پای چپ</summary>
public int GoldLeftLegTotal { get; set; }
/// <summary>تعداد اعضای فعال با پکیج طلایی جاری در پای راست</summary>
public int GoldRightLegTotal { get; set; }
/// <summary>تعداد اعضای فعال با پکیج نقره‌ای جاری در پای چپ</summary>
public int SilverLeftLegTotal { get; set; }
/// <summary>تعداد اعضای فعال با پکیج نقره‌ای جاری در پای راست</summary>
public int SilverRightLegTotal { get; set; }
}
/// <summary>
@@ -43,6 +60,16 @@ public class FlatNetworkNodeDto
/// کد معرف کاربر - فقط برای کاربران فعال در باشگاه نمایش داده شود
/// </summary>
public string? ReferralCode { get; set; }
/// <summary>
/// نام پکیج موثر (مثلاً پکیج طلایی / نقره‌ای)
/// </summary>
public string? PackageName { get; set; }
public int GoldLeftLegTotal { get; set; }
public int GoldRightLegTotal { get; set; }
public int SilverLeftLegTotal { get; set; }
public int SilverRightLegTotal { get; set; }
}
/// <summary>
@@ -81,7 +108,12 @@ public class NetworkTreeDto
IsClubActive = node.IsClubActive,
ActivationWeekNumber = node.ActivationWeekNumber,
JoinedAt = node.JoinedAt,
ReferralCode = node.ReferralCode
ReferralCode = node.ReferralCode,
PackageName = node.PackageName,
GoldLeftLegTotal = node.GoldLeftLegTotal,
GoldRightLegTotal = node.GoldRightLegTotal,
SilverLeftLegTotal = node.SilverLeftLegTotal,
SilverRightLegTotal = node.SilverRightLegTotal
};
result.Add(flatNode);
@@ -114,10 +146,19 @@ public class LastMemberDto
/// </summary>
public class NetworkStatisticsDto
{
/// <summary>اعضای فعال باشگاه در پای چپ</summary>
public int LeftLegCount { get; set; }
/// <summary>اعضای فعال باشگاه در پای راست</summary>
public int RightLegCount { get; set; }
/// <summary>LeftLegCount + RightLegCount</summary>
public int TotalMembers { get; set; }
public int TreeDepth { get; set; }
public string WeakerLeg { get; set; } = string.Empty; // "Left" or "Right"
public LastMemberDto? LastMember { get; set; }
/// <summary>تعداد اعضای فعال با پکیج طلایی/نقره‌ای جاری در هر پا — نوار پایین شجره</summary>
public int GoldLeftLegTotal { get; set; }
public int GoldRightLegTotal { get; set; }
public int SilverLeftLegTotal { get; set; }
public int SilverRightLegTotal { get; set; }
}
@@ -96,6 +96,10 @@ public class NetworkMembershipService
TotalMembers = response.TotalMembers,
TreeDepth = response.MaxDepth,
WeakerLeg = response.WeakerLeg,
GoldLeftLegTotal = response.GoldLeftLegTotal,
GoldRightLegTotal = response.GoldRightLegTotal,
SilverLeftLegTotal = response.SilverLeftLegTotal,
SilverRightLegTotal = response.SilverRightLegTotal,
LastMember = response.LastMember != null ? new LastMemberDto
{
UserId = response.LastMember.UserId,
@@ -137,8 +141,14 @@ public class NetworkMembershipService
IsActive = node.IsActive,
IsClubActive = node.IsClubActive,
ReferralCode = node.ReferralCode,
PackageName = string.IsNullOrWhiteSpace(node.PackageName) ? null : node.PackageName,
GoldLeftLegTotal = node.GoldLeftLegTotal,
GoldRightLegTotal = node.GoldRightLegTotal,
SilverLeftLegTotal = node.SilverLeftLegTotal,
SilverRightLegTotal = node.SilverRightLegTotal,
ActivationWeekNumber = node.ActivationWeekDefinitionId?.ToString(),
JoinedAt = node.JoinedAt?.ToDateTime(),
JoinedAt = node.UserCreated?.ToDateTime()
?? node.JoinedAt?.ToDateTime(),
LeftChild = MapNodeFromProto(node.LeftChild),
RightChild = MapNodeFromProto(node.RightChild)
};
@@ -97,22 +97,4 @@ public class OrderService
return 0.10;
}
}
/// <summary>
/// محاسبه PV سفارش
/// </summary>
public async Task<CalculateOrderPVResponse?> CalculateOrderPVAsync(long orderId)
{
try
{
return await _userOrderContractClient.CalculateOrderPVAsync(new CalculateOrderPVRequest
{
OrderId = orderId
});
}
catch
{
return null;
}
}
}
@@ -71,12 +71,12 @@ public class ProductService
return result.Products;
}
public Task<ProductListResult> GetTopSellingAsync(int count = 6)
=> GetProductsPagedAsync(sortBy: "SaleCount desc", page: 1, pageSize: count);
public Task<ProductListResult> GetTopSellingAsync(int count = 6, bool? inStock = null)
=> GetProductsPagedAsync(sortBy: "SaleCount desc", page: 1, pageSize: count, inStock: inStock);
public async Task<ProductListResult> GetProductsPagedAsync(
string? query = null, long? categoryId = null, string? sortBy = null,
int page = 1, int pageSize = 12)
int page = 1, int pageSize = 12, bool? inStock = null)
{
try
{
@@ -87,20 +87,25 @@ public class ProductService
PageNumber = page,
PageSize = pageSize
},
Filter = new GetAllProductsByFilterFilter
{
Title = query ?? string.Empty,
Description = query ?? string.Empty,
ShortInfomation = query ?? string.Empty,
FullInformation = query ?? string.Empty
}
Filter = new GetAllProductsByFilterFilter()
};
// فقط Title — CMS فیلترها را AND می‌زند؛ ارسال query در همه فیلدها جستجو را می‌شکند
if (!string.IsNullOrWhiteSpace(query))
{
request.Filter.Title = query.Trim();
}
if (categoryId is { } value)
{
request.Filter.CategoryId = value;
}
if (inStock.HasValue)
{
request.Filter.InStock = inStock.Value;
}
if (!string.IsNullOrEmpty(sortBy))
{
request.SortBy = sortBy;
@@ -132,30 +137,66 @@ public class ProductService
public async Task<Product?> GetByIdAsync(long id)
{
if (TryGetCachedProduct(id, out var cached) && HasDetailedData(cached))
{
if (id <= 0)
return null;
TryGetCachedProduct(id, out var cached);
if (cached is not null && HasDetailedData(cached))
return cached;
// جزئیات کامل (گالری + دسته‌بندی)
var detailed = await TryFetchDetailAsync(id);
if (detailed is not null)
return detailed;
// fallback: همان API لیست محصولات — ناموجودها را هم برمی‌گرداند
var fromFilter = await TryFetchByFilterIdAsync(id);
if (fromFilter is not null)
return fromFilter;
return cached;
}
private async Task<Product?> TryFetchDetailAsync(long id)
{
try
{
var resp = await _client.GetProductsAsync(new GetProductsRequest { Id = id });
if (resp == null)
{
if (resp is null || resp.Id <= 0)
return null;
}
return MapAndCache(resp);
}
catch
{
if (cached is not null)
{
return cached;
return null;
}
}
TryGetCachedProduct(id, out var result);
return result;
private async Task<Product?> TryFetchByFilterIdAsync(long id)
{
try
{
var resp = await _client.GetAllProductsByFilterAsync(new GetAllProductsByFilterRequest
{
PaginationState = new CMSMicroservice.Protobuf.Protos.PaginationState
{
PageNumber = 1,
PageSize = 1
},
Filter = new GetAllProductsByFilterFilter { Id = id }
});
var model = resp.Models.FirstOrDefault(m => m.Id == id);
if (model is null)
return null;
return MapAndCache(resp.Models).FirstOrDefault(p => p.Id == id);
}
catch
{
return null;
}
}
@@ -21,9 +21,11 @@ public static class RouteConstants
public const string Settings = "/profile/settings";
public const string ChangePassword = "/profile/change-password";
public const string Tree = "/profile/tree";
public const string Invitees = "/profile/invitees";
public const string Wallet = "/profile/wallet";
public const string MagicWallet = "/profile/magic-wallet";
public const string ChargeDiscountWallet = "/profile/charge-discount-wallet";
public const string ChargeCreditWallet = "/profile/charge-credit-wallet";
public const string WithdrawalRequests = "/profile/withdrawal-requests";
}
@@ -0,0 +1,213 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Options;
namespace FrontOffice.Main.Utilities.Seo;
public class SeoMetadataProvider
{
private readonly BlogPostService _blogPostService;
private readonly SeoSettings _settings;
private static readonly Dictionary<string, (string Title, string Description)> StaticPages = new(StringComparer.OrdinalIgnoreCase)
{
["/"] = (
"کارابازار سلامت | باشگاه مشتریان KBS",
"باشگاه مشتریان کارابازار سلامت — رشد تیم، فروش واقعی و پاداش شفاف. فروشگاه محصولات سلامت، کیف پول و ثبت‌نام رایگان."),
[RouteConstants.About.Index] = (
"درباره ما | کارابازار سلامت",
"آشنایی با کارابازار سلامت، باشگاه مشتریان KBS و خدمات فروشگاه محصولات سلامت."),
[RouteConstants.FAQ.Index] = (
"سوالات متداول | کارابازار سلامت",
"پاسخ سوالات متداول درباره باشگاه مشتریان، کیف پول، پاداش‌ها و خرید از کارابازار سلامت."),
[RouteConstants.Contact.Index] = (
"ارتباط با ما | کارابازار سلامت",
"تماس با تیم پشتیبانی کارابازار سلامت — سوالات، پیشنهادات و پشتیبانی کاربران."),
[RouteConstants.Licenses.Index] = (
"مجوزها و گواهینامه‌ها | کارابازار سلامت",
"مجوزها، گواهینامه‌ها و مدارک رسمی کارابازار سلامت."),
[RouteConstants.Blog.Index] = (
"بلاگ | کارابازار سلامت",
"آخرین مقالات و اخبار باشگاه مشتریان کارابازار سلامت."),
[RouteConstants.Store.Products] = (
"محصولات | کارابازار سلامت",
"فروشگاه محصولات سلامت کارابازار — خرید آنلاین با پاداش باشگاه مشتریان."),
[RouteConstants.Store.Categories] = (
"دسته‌بندی‌ها | کارابازار سلامت",
"دسته‌بندی محصولات فروشگاه کارابازار سلامت."),
[RouteConstants.Package.List] = (
"پکیج‌ها | کارابازار سلامت",
"پکیج‌های عضویت باشگاه مشتریان کارابازار سلامت."),
[RouteConstants.Club.Membership] = (
"عضویت باشگاه مشتریان | کارابازار سلامت",
"عضویت در باشگاه مشتریان KBS و بهره‌مندی از مزایا و پاداش‌ها."),
[RouteConstants.Club.Features] = (
"ویژگی‌های باشگاه مشتریان | کارابازار سلامت",
"امکانات و ویژگی‌های باشگاه مشتریان کارابازار سلامت."),
[RouteConstants.DiscountStore.Products] = (
"فروشگاه اعتباری | کارابازار سلامت",
"خرید از فروشگاه اعتباری کارابازار سلامت با کیف پول تخفیفی."),
[RouteConstants.Registration.Wizard] = (
"ثبت‌نام | کارابازار سلامت",
"ثبت‌نام سریع در باشگاه مشتریان کارابازار سلامت."),
[RouteConstants.Gateway.StoreChooser] = (
"فروشگاه‌ها | کارابازار سلامت",
"انتخاب فروشگاه اصلی یا اعتباری در کارابازار سلامت."),
};
private static readonly HashSet<string> NoIndexPaths = new(StringComparer.OrdinalIgnoreCase)
{
"/wishlist",
"/wp-admin",
"/wp-login.php",
"/feed",
"/xmlrpc.php",
};
private static readonly HashSet<string> NoIndexPrefixes = new(StringComparer.OrdinalIgnoreCase)
{
RouteConstants.Profile.Index,
"/profile/",
RouteConstants.Checkout.Index,
RouteConstants.Store.Cart,
RouteConstants.Store.CheckoutSummary,
RouteConstants.Store.Orders,
"/order/",
RouteConstants.DiscountStore.Cart,
RouteConstants.DiscountStore.Checkout,
RouteConstants.DiscountStore.Orders,
"/discount-store/order/",
RouteConstants.Gateway.OrdersChooser,
RouteConstants.Gateway.CartChooser,
RouteConstants.Commission.Dashboard,
"/commission/",
RouteConstants.Network.Statistics,
"/network/",
RouteConstants.Package.MyPackages,
"/wp-content/",
"/wp-includes/",
};
private static readonly Regex ProductDetailPattern = new(@"^/product/\d+$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
private static readonly Regex DiscountProductPattern = new(@"^/discount-store/product/\d+$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
private static readonly Regex PackageDetailPattern = new(@"^/package/\d+$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
private static readonly Regex CheckoutPackagePattern = new(@"^/checkout/\d+$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
public SeoMetadataProvider(BlogPostService blogPostService, IOptions<SeoSettings> settings)
{
_blogPostService = blogPostService;
_settings = settings.Value;
}
public async Task<SeoPageMetadata> GetForPathAsync(string path, CancellationToken cancellationToken = default)
{
var normalizedPath = NormalizePath(path);
var baseUrl = _settings.SiteUrl.TrimEnd('/');
if (IsNoIndexPath(normalizedPath))
{
return new SeoPageMetadata
{
Title = _settings.SiteName,
Description = _settings.DefaultDescription,
CanonicalUrl = $"{baseUrl}{normalizedPath}",
NoIndex = true
};
}
if (normalizedPath.StartsWith(RouteConstants.Blog.Post, StringComparison.OrdinalIgnoreCase)
&& normalizedPath.Length > RouteConstants.Blog.Post.Length)
{
var slug = normalizedPath[RouteConstants.Blog.Post.Length..].Trim('/');
if (!string.IsNullOrWhiteSpace(slug))
{
var post = await _blogPostService.GetBySlugAsync(slug);
if (post != null)
{
var description = !string.IsNullOrWhiteSpace(post.Summary)
? post.Summary
: $"مقاله {post.Title} — بلاگ کارابازار سلامت";
return new SeoPageMetadata
{
Title = $"{post.Title} | بلاگ کارابازار سلامت",
Description = description,
CanonicalUrl = $"{baseUrl}{RouteConstants.Blog.Post}{post.Slug}",
CrawlableContent = $"{post.Title}. {description}"
};
}
}
}
if (StaticPages.TryGetValue(normalizedPath, out var page))
{
return new SeoPageMetadata
{
Title = page.Title,
Description = page.Description,
CanonicalUrl = $"{baseUrl}{normalizedPath}",
CrawlableContent = normalizedPath == "/" ? GetHomeCrawlableContent() : null
};
}
if (IsIndexableDynamicRoute(normalizedPath))
{
return new SeoPageMetadata
{
Title = _settings.SiteName,
Description = _settings.DefaultDescription,
CanonicalUrl = $"{baseUrl}{normalizedPath}"
};
}
return new SeoPageMetadata
{
Title = _settings.SiteName,
Description = _settings.DefaultDescription,
CanonicalUrl = $"{baseUrl}{normalizedPath}",
NoIndex = true
};
}
private static bool IsIndexableDynamicRoute(string path) =>
ProductDetailPattern.IsMatch(path)
|| DiscountProductPattern.IsMatch(path)
|| PackageDetailPattern.IsMatch(path)
|| CheckoutPackagePattern.IsMatch(path);
private static string NormalizePath(string path)
{
if (string.IsNullOrWhiteSpace(path))
return "/";
var normalized = path.Split('?', '#')[0].Trim();
if (!normalized.StartsWith('/'))
normalized = "/" + normalized;
if (normalized.Length > 1 && normalized.EndsWith('/'))
normalized = normalized.TrimEnd('/');
return normalized;
}
private static bool IsNoIndexPath(string path)
{
if (NoIndexPaths.Contains(path))
return true;
foreach (var prefix in NoIndexPrefixes)
{
if (path.Equals(prefix, StringComparison.OrdinalIgnoreCase)
|| path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static string GetHomeCrawlableContent() =>
"کارابازار سلامت (KBS) — باشگاه مشتریان و فروشگاه محصولات سلامت. " +
"با عضویت در باشگاه مشتریان KBS از پاداش شفاف، کیف پول جادویی، فروشگاه اعتباری و سازمان فروش بهره‌مند شوید. " +
"ثبت‌نام رایگان در kbs2.ir.";
}
@@ -0,0 +1,10 @@
namespace FrontOffice.Main.Utilities.Seo;
public sealed class SeoPageMetadata
{
public required string Title { get; init; }
public required string Description { get; init; }
public required string CanonicalUrl { get; init; }
public string? CrawlableContent { get; init; }
public bool NoIndex { get; init; }
}
@@ -0,0 +1,11 @@
namespace FrontOffice.Main.Utilities.Seo;
public class SeoSettings
{
public const string SectionName = "Seo";
public string SiteUrl { get; set; } = "https://kbs2.ir";
public string SiteName { get; set; } = "کارابازار سلامت";
public string DefaultDescription { get; set; } =
"باشگاه مشتریان کارابازار سلامت (KBS) — فروشگاه محصولات سلامت، پاداش شفاف، کیف پول و سازمان فروش.";
}
@@ -0,0 +1,117 @@
using System.Text;
using System.Xml;
using System.Xml.Linq;
using Microsoft.Extensions.Options;
namespace FrontOffice.Main.Utilities.Seo;
public class SitemapGenerator
{
private readonly BlogPostService _blogPostService;
private readonly SeoSettings _settings;
private static readonly string[] StaticPaths =
[
RouteConstants.Main.MainPage,
RouteConstants.About.Index,
RouteConstants.FAQ.Index,
RouteConstants.Contact.Index,
RouteConstants.Licenses.Index,
RouteConstants.Blog.Index,
RouteConstants.Store.Products,
RouteConstants.Package.List,
RouteConstants.Club.Membership,
RouteConstants.Club.Features,
RouteConstants.DiscountStore.Products,
RouteConstants.Registration.Wizard,
];
public SitemapGenerator(BlogPostService blogPostService, IOptions<SeoSettings> settings)
{
_blogPostService = blogPostService;
_settings = settings.Value;
}
public async Task<string> GenerateAsync(CancellationToken cancellationToken = default)
{
var baseUrl = _settings.SiteUrl.TrimEnd('/');
var ns = XNamespace.Get("http://www.sitemaps.org/schemas/sitemap/0.9");
var urls = new List<XElement>();
foreach (var path in StaticPaths)
{
urls.Add(CreateUrlElement(ns, $"{baseUrl}{path}", priority: path == "/" ? "1.0" : "0.8"));
}
var page = 1;
const int pageSize = 100;
while (true)
{
var result = await _blogPostService.GetPublishedPostsAsync(page: page, pageSize: pageSize);
if (result.Posts.Count == 0)
break;
foreach (var post in result.Posts)
{
if (string.IsNullOrWhiteSpace(post.Slug))
continue;
var lastMod = post.PublishedAt?.ToString("yyyy-MM-dd") ?? DateTime.UtcNow.ToString("yyyy-MM-dd");
urls.Add(CreateUrlElement(
ns,
$"{baseUrl}{RouteConstants.Blog.Post}{post.Slug}",
lastMod: lastMod,
priority: "0.6"));
}
if (page >= result.TotalPages)
break;
page++;
}
var document = new XDocument(
new XDeclaration("1.0", "utf-8", null),
new XElement(ns + "urlset", urls));
var settings = new XmlWriterSettings
{
Encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
Indent = true,
OmitXmlDeclaration = false
};
using var stream = new MemoryStream();
using (var writer = XmlWriter.Create(stream, settings))
{
document.Save(writer);
}
return Encoding.UTF8.GetString(stream.ToArray());
}
public async Task<byte[]> GenerateBytesAsync(CancellationToken cancellationToken = default)
{
var xml = await GenerateAsync(cancellationToken);
return Encoding.UTF8.GetBytes(xml);
}
private static XElement CreateUrlElement(
XNamespace ns,
string loc,
string? lastMod = null,
string changefreq = "weekly",
string priority = "0.5")
{
var element = new XElement(ns + "url",
new XElement(ns + "loc", loc),
new XElement(ns + "changefreq", changefreq),
new XElement(ns + "priority", priority));
if (!string.IsNullOrWhiteSpace(lastMod))
element.Add(new XElement(ns + "lastmod", lastMod));
return element;
}
}
@@ -0,0 +1,94 @@
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Primitives;
namespace FrontOffice.Main.Utilities;
public enum ProductSortOption
{
PriceDesc,
PriceAsc,
Newest,
Title
}
/// <summary>
/// وضعیت لیست فروشگاه در query string — فقط برای /products و /discount-store.
/// </summary>
public sealed class ShopListQueryState
{
public string Query { get; init; } = string.Empty;
public string? Sort { get; init; }
public long? CategoryId { get; init; }
public int Pages { get; init; } = 1;
public static ShopListQueryState Parse(Uri uri)
{
var q = QueryHelpers.ParseQuery(uri.Query);
return new ShopListQueryState
{
Query = GetString(q, "q") ?? string.Empty,
Sort = GetString(q, "sort"),
CategoryId = GetLong(q, "category"),
Pages = Math.Max(1, GetInt(q, "pages") ?? 1)
};
}
public string ToRelativeUrl(string path)
{
var dict = new Dictionary<string, string?>();
if (!string.IsNullOrWhiteSpace(Query))
dict["q"] = Query.Trim();
if (!string.IsNullOrWhiteSpace(Sort) && !string.Equals(Sort, "price-desc", StringComparison.OrdinalIgnoreCase))
dict["sort"] = Sort;
if (CategoryId is > 0)
dict["category"] = CategoryId.Value.ToString();
if (Pages > 1)
dict["pages"] = Pages.ToString();
return dict.Count == 0
? path
: QueryHelpers.AddQueryString(path, dict!);
}
public bool Matches(ShopListQueryState other) =>
string.Equals(Query?.Trim() ?? "", other.Query?.Trim() ?? "", StringComparison.Ordinal)
&& string.Equals(NormalizeSort(Sort), NormalizeSort(other.Sort), StringComparison.OrdinalIgnoreCase)
&& CategoryId == other.CategoryId
&& Pages == other.Pages;
public static string NormalizeSort(string? sort) =>
string.IsNullOrWhiteSpace(sort) ? "price-desc" : sort.Trim().ToLowerInvariant();
public static string ToSortKey(ProductSortOption option) => option switch
{
ProductSortOption.PriceAsc => "price-asc",
ProductSortOption.Newest => "newest",
ProductSortOption.Title => "title",
_ => "price-desc"
};
public static ProductSortOption ParseSortOption(string? sort) => NormalizeSort(sort) switch
{
"price-asc" => ProductSortOption.PriceAsc,
"newest" => ProductSortOption.Newest,
"title" => ProductSortOption.Title,
_ => ProductSortOption.PriceDesc
};
public static string ToApiSortBy(ProductSortOption option) => option switch
{
ProductSortOption.PriceAsc => "price asc",
ProductSortOption.Newest => "id desc",
ProductSortOption.Title => "title asc",
_ => "price desc"
};
private static string? GetString(Dictionary<string, StringValues> q, string key) =>
q.TryGetValue(key, out var v) ? v.FirstOrDefault() : null;
private static long? GetLong(Dictionary<string, StringValues> q, string key) =>
long.TryParse(GetString(q, key), out var n) ? n : null;
private static int? GetInt(Dictionary<string, StringValues> q, string key) =>
int.TryParse(GetString(q, key), out var n) ? n : null;
}
@@ -0,0 +1,69 @@
using System.Text.Json;
using Microsoft.JSInterop;
namespace FrontOffice.Main.Utilities;
/// <summary>
/// اسکرول/فوکوس محصول پس از بازگشت از جزئیات — کلیدهای محدود به لیست فروشگاه (نه لندینگ).
/// </summary>
public static class ShopListScrollRestore
{
public const string StoreKey = "fo:store:scroll";
public const string DiscountKey = "fo:discount:scroll";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public static async Task SaveAsync(IJSRuntime js, string storageKey, long productId)
{
double scrollY = 0;
try
{
scrollY = await js.InvokeAsync<double>("eval", "window.scrollY || window.pageYOffset || 0");
}
catch
{
// ignore
}
var payload = JsonSerializer.Serialize(new ScrollPayload(productId, scrollY), JsonOptions);
await js.InvokeVoidAsync("sessionStorage.setItem", storageKey, payload);
}
public static async Task<ScrollPayload?> TakeAsync(IJSRuntime js, string storageKey)
{
try
{
var raw = await js.InvokeAsync<string?>("sessionStorage.getItem", storageKey);
await js.InvokeVoidAsync("sessionStorage.removeItem", storageKey);
if (string.IsNullOrWhiteSpace(raw))
return null;
return JsonSerializer.Deserialize<ScrollPayload>(raw, JsonOptions);
}
catch
{
return null;
}
}
public static async Task RestoreAsync(IJSRuntime js, ScrollPayload payload)
{
try
{
await js.InvokeVoidAsync("eval",
$@"(function(){{
var el = document.getElementById('shop-product-{payload.ProductId}');
if (el) {{ el.scrollIntoView({{ block: 'center', behavior: 'instant' }}); return; }}
window.scrollTo(0, {payload.ScrollY.ToString(System.Globalization.CultureInfo.InvariantCulture)});
}})()");
}
catch
{
// ignore
}
}
public sealed record ScrollPayload(long ProductId, double ScrollY);
}
@@ -11,4 +11,5 @@ public class UserAuthInfo
public bool IsSignMainContract { get; set; }
public bool HasPurchasedPackage { get; set; }
public bool IsClubMemberActive { get; set; }
public bool HasAddress { get; set; }
}
@@ -266,6 +266,26 @@ public class WalletService
}
}
public async Task<(bool Success, string? GatewayUrl, string? Error)> InitiateCreditChargeAsync(long amount)
{
try
{
var response = await _client.InitiateCreditChargeAsync(new InitiateCreditChargeRequest
{
Amount = amount
});
if (response.IsSuccess)
return (true, response.GatewayUrl, null);
return (false, null, response.ErrorMessage);
}
catch (Grpc.Core.RpcException ex)
{
return (false, null, ex.Status.Detail ?? "خطا در ارتباط با سرور");
}
}
// ============= Wallet Verify Methods =============
public async Task<(bool Success, string Message)> VerifyMagicChargeAsync(string authority, string status)
@@ -304,6 +324,24 @@ public class WalletService
}
}
public async Task<(bool Success, string Message)> VerifyCreditChargeAsync(string authority, string status)
{
try
{
var response = await _client.VerifyCreditChargeAsync(new VerifyWalletChargeRequest
{
Authority = authority,
Status = status
});
return (response.Success, response.Message);
}
catch (Grpc.Core.RpcException ex)
{
return (false, ex.Status.Detail ?? "خطا در بررسی وضعیت پرداخت");
}
}
public int GetWalletMode()
{
try
@@ -14,5 +14,10 @@
"Microsoft.AspNetCore": "Warning"
}
},
"Seo": {
"SiteUrl": "https://kbs2.ir",
"SiteName": "کارابازار سلامت",
"DefaultDescription": "باشگاه مشتریان کارابازار سلامت (KBS) — فروشگاه محصولات سلامت، پاداش شفاف، کیف پول و شبکه فروش."
},
"AllowedHosts": "*"
}
@@ -1,6 +1,18 @@
{
"Seo": {
"SiteUrl": "https://kbs1.ir",
"SiteName": "کارابازار سلامت",
"DefaultDescription": "باشگاه مشتریان کارابازار سلامت (KBS) — فروشگاه محصولات سلامت، پاداش شفاف، کیف پول و سازمان فروش."
},
"GwUrl": "https://cms.se.kbs1.ir",
"DownloadUrl": "",
"TwentyCard": {
"CreditPortalUrl": "https://userpanel.20card.ir/"
},
"Landing": {
"ShowStepsSection": false,
"ShowFeaturesSection": false
},
"EncryptionSettings": {
"Key": "kmcQ3XTmH4mrdh8VHziuscyf8LLYjG//Kyni81nH/0E=",
"IV": "1wyF3Tt142MOkCpIyCxh/g=="
+12
View File
@@ -1,6 +1,18 @@
{
"Seo": {
"SiteUrl": "http://localhost:5268",
"SiteName": "کارابازار سلامت",
"DefaultDescription": "باشگاه مشتریان کارابازار سلامت (KBS) — فروشگاه محصولات سلامت، پاداش شفاف، کیف پول و سازمان فروش."
},
"GwUrl": "https://cms.se.kbs1.ir",
"DownloadUrl": "",
"TwentyCard": {
"CreditPortalUrl": "https://userpanel.20card.ir/"
},
"Landing": {
"ShowStepsSection": false,
"ShowFeaturesSection": false
},
"EncryptionSettings": {
"Key": "kmcQ3XTmH4mrdh8VHziuscyf8LLYjG//Kyni81nH/0E=",
"IV": "1wyF3Tt142MOkCpIyCxh/g=="
+62 -3
View File
@@ -118,12 +118,40 @@
line-height: 1.2;
}
.node-registered-at {
font-size: 8px;
color: #6c757d;
margin-top: 1px;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.node-level-sm {
font-size: 9px;
color: #6c757d;
margin-top: 1px;
}
.org-node-card .package-name-badge {
display: inline-flex;
align-items: center;
height: 14px;
padding: 0 6px;
margin-top: 2px;
border-radius: 7px;
font-size: 9px;
color: #546e7a;
background: #eceff1;
border: 1px solid #b0bec5;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 120px;
}
/* D3: تعداد پکیج طلایی/نقره‌ای فقط در نوار پایین شجره (.org-chart-stats) */
/* Referral Code - only for club active members */
.node-referral {
display: flex;
@@ -353,14 +381,27 @@
/* Stats Bar */
.org-chart-stats {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
gap: 8px;
padding: 12px 16px;
background: #f8f9fa;
border-top: 1px solid #e9ecef;
}
.org-chart-stats-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 24px;
}
.org-chart-stats-row.leg-scores {
gap: 12px;
}
.stat-item {
display: flex;
align-items: center;
@@ -368,6 +409,21 @@
font-size: 13px;
}
.stat-item.leg-score-gold .stat-label {
color: #b8860b;
font-weight: 600;
}
.stat-item.leg-score-silver .stat-label {
color: #607d8b;
font-weight: 600;
}
.stat-sep {
color: #adb5bd;
margin: 0 2px;
}
.stat-label {
color: #6c757d;
}
@@ -431,11 +487,14 @@
}
.org-chart-stats {
flex-wrap: wrap;
gap: 12px;
gap: 8px;
padding: 10px;
}
.org-chart-stats-row {
gap: 12px;
}
.stat-item {
font-size: 12px;
}
@@ -1617,6 +1617,12 @@ html, body {
overflow: hidden;
}
/* ── Landing TwentyCard credit promo ── */
.landing-twentycard-box {
background: var(--ds-info-soft);
border: 1px solid rgba(59, 130, 246, 0.22);
}
/* ── Register Wizard Unified ── */
.wizard-section {
min-height: 80vh;
+9 -2
View File
@@ -38,7 +38,7 @@ window.OrgChart = {
.container('#' + containerId)
.data(data)
.nodeWidth((d) => 140)
.nodeHeight((d) => 70)
.nodeHeight((d) => 98)
.childrenMargin((d) => 50)
.compactMarginBetween((d) => 15)
.compactMarginPair((d) => 15)
@@ -67,6 +67,11 @@ window.OrgChart = {
// Avatar - first letter of name
const firstChar = data.fullName ? data.fullName.charAt(0) : '?';
const packageName = data.packageName || data.PackageName || '';
const packageBadge = packageName
? `<div class="package-name-badge">${packageName}</div>`
: '';
// نمایش کد معرف فقط برای کاربران فعال در باشگاه
const referralCodeHtml = data.isClubActive && data.referralCode
? `<div class="node-referral" onclick="event.stopPropagation(); OrgChart.copyReferralCode('${data.referralCode}');" title="کپی کد معرف">
@@ -81,7 +86,9 @@ window.OrgChart = {
<div class="node-avatar-sm">${firstChar}</div>
<div class="node-info">
<div class="node-name-sm">${data.fullName || 'بدون نام'}</div>
${data.registeredAt ? `<div class="node-registered-at">${data.registeredAt}</div>` : ''}
<div class="node-level-sm">L${data.level || 0}${!isRoot ? ' • ' + (data.position === 'Left' ? 'چپ' : 'راست') : ''}</div>
${packageBadge}
${referralCodeHtml}
</div>
</div>
@@ -290,7 +297,7 @@ window.OrgChart = {
isActive: node.isActive !== false,
isClubActive: node.isClubActive || false,
activationWeekNumber: node.activationWeekNumber,
joinedAt: node.joinedAt
registeredAt: node.registeredAt || node.joinedAt || ''
};
result.push(flatNode);
+21
View File
@@ -0,0 +1,21 @@
User-agent: *
Allow: /
Disallow: /profile/
Disallow: /checkout
Disallow: /checkout-summary
Disallow: /cart
Disallow: /orders
Disallow: /order/
Disallow: /discount-store/cart
Disallow: /discount-store/checkout
Disallow: /discount-store/orders
Disallow: /discount-store/order/
Disallow: /my-orders
Disallow: /my-cart
Disallow: /wishlist
Disallow: /commission/
Disallow: /network/
Disallow: /my-packages
Sitemap: https://kbs2.ir/sitemap.xml