├── .github └── workflows │ └── main_restore-course.yml ├── .gitignore ├── .vscode ├── launch.json └── settings.json ├── API ├── API.csproj ├── Controllers │ ├── AccountController.cs │ ├── BaseApiController.cs │ ├── BasketController.cs │ ├── BuggyController.cs │ ├── FallbackController.cs │ ├── OrdersController.cs │ ├── PaymentsController.cs │ ├── ProductsController.cs │ └── WeatherForecastController.cs ├── DTOs │ ├── BasketDto.cs │ ├── BasketItemDto.cs │ ├── CreateOrderDto.cs │ ├── CreateProductDto.cs │ ├── OrderDto.cs │ ├── OrderItemDto.cs │ ├── RegisterDto.cs │ └── UpdateProductDto.cs ├── Data │ ├── DbInitializer.cs │ ├── Migrations │ │ ├── 20241130080245_SqlServerInitial.Designer.cs │ │ ├── 20241130080245_SqlServerInitial.cs │ │ ├── 20241210024925_PublicIdAdded.Designer.cs │ │ ├── 20241210024925_PublicIdAdded.cs │ │ ├── 20241213031915_CouponAdded.Designer.cs │ │ ├── 20241213031915_CouponAdded.cs │ │ └── StoreContextModelSnapshot.cs │ └── StoreContext.cs ├── Entities │ ├── Address.cs │ ├── AppCoupon.cs │ ├── Basket.cs │ ├── BasketItem.cs │ ├── OrderAggregate │ │ ├── Order.cs │ │ ├── OrderItem.cs │ │ ├── OrderStatus.cs │ │ ├── PaymentSummary.cs │ │ ├── ProductItemOrdered.cs │ │ └── ShippingAddress.cs │ ├── Product.cs │ └── User.cs ├── Extensions │ ├── BasketExtensions.cs │ ├── ClaimsPrincipalExtensions.cs │ ├── HttpExtensions.cs │ ├── OrderExtensions.cs │ └── ProductExtensions.cs ├── Middleware │ └── ExceptionMiddleware.cs ├── Program.cs ├── Properties │ └── launchSettings.json ├── RequestHelpers │ ├── CloudinarySettings.cs │ ├── MappingProfiles.cs │ ├── PagedList.cs │ ├── PaginationMetadata.cs │ ├── PaginationParams.cs │ └── ProductParams.cs ├── Services │ ├── DiscountService.cs │ ├── ImageService.cs │ └── PaymentsService.cs ├── WeatherForecast.cs ├── appsettings.Development.json ├── store.db └── wwwroot │ ├── assets │ ├── index-Bc1grMCE.js │ ├── index-DFo9Q7KV.css │ ├── roboto-cyrillic-300-normal-DJfICpyc.woff2 │ ├── roboto-cyrillic-300-normal-Dg7J0kAT.woff │ ├── roboto-cyrillic-400-normal-BiRJyiea.woff2 │ ├── roboto-cyrillic-400-normal-JN0iKxGs.woff │ ├── roboto-cyrillic-500-normal-YnJLGrUm.woff │ ├── roboto-cyrillic-500-normal-_hamcpv8.woff2 │ ├── roboto-cyrillic-700-normal-BJaAVvFw.woff │ ├── roboto-cyrillic-700-normal-jruQITdB.woff2 │ ├── roboto-cyrillic-ext-300-normal-BLLmCegk.woff │ ├── roboto-cyrillic-ext-300-normal-Chhwl1Jq.woff2 │ ├── roboto-cyrillic-ext-400-normal-D76n7Daw.woff2 │ ├── roboto-cyrillic-ext-400-normal-b0JluIOJ.woff │ ├── roboto-cyrillic-ext-500-normal-37WQE4S0.woff │ ├── roboto-cyrillic-ext-500-normal-BJvL3D7h.woff2 │ ├── roboto-cyrillic-ext-700-normal-CyZgh00P.woff2 │ ├── roboto-cyrillic-ext-700-normal-DXzexxfu.woff │ ├── roboto-greek-300-normal-Bx8edVml.woff2 │ ├── roboto-greek-300-normal-D3gN5oZ1.woff │ ├── roboto-greek-400-normal-IIc_WWwF.woff │ ├── roboto-greek-400-normal-LPh2sqOm.woff2 │ ├── roboto-greek-500-normal-Bg8BLohm.woff2 │ ├── roboto-greek-500-normal-CdRewbqV.woff │ ├── roboto-greek-700-normal-1IZ-NEfb.woff │ ├── roboto-greek-700-normal-Bs05n1ZH.woff2 │ ├── roboto-latin-300-normal-BZ6gvbSO.woff │ ├── roboto-latin-300-normal-BizgZZ3y.woff2 │ ├── roboto-latin-400-normal-BVyCgWwA.woff │ ├── roboto-latin-400-normal-DXyFPIdK.woff2 │ ├── roboto-latin-500-normal-C6iW8rdg.woff2 │ ├── roboto-latin-500-normal-rpP1_v3s.woff │ ├── roboto-latin-700-normal-BWcFiwQV.woff │ ├── roboto-latin-700-normal-CbYYDfWS.woff2 │ ├── roboto-latin-ext-300-normal-BzRVPTS2.woff2 │ ├── roboto-latin-ext-300-normal-Djx841zm.woff │ ├── roboto-latin-ext-400-normal-BSFkPfbf.woff │ ├── roboto-latin-ext-400-normal-DgXbz5gU.woff2 │ ├── roboto-latin-ext-500-normal-DvHxAkTn.woff │ ├── roboto-latin-ext-500-normal-OQJhyaXd.woff2 │ ├── roboto-latin-ext-700-normal-Ba-CAIIA.woff │ ├── roboto-latin-ext-700-normal-DchBbzVz.woff2 │ ├── roboto-vietnamese-300-normal-CAomnZLO.woff │ ├── roboto-vietnamese-300-normal-PZa9KE_J.woff2 │ ├── roboto-vietnamese-400-normal-D5pJwT9g.woff │ ├── roboto-vietnamese-400-normal-DhTUfTw_.woff2 │ ├── roboto-vietnamese-500-normal-LvuCHq7y.woff │ ├── roboto-vietnamese-500-normal-p0V0BAAE.woff2 │ ├── roboto-vietnamese-700-normal-B4Nagvlm.woff │ └── roboto-vietnamese-700-normal-CBbheh0s.woff2 │ ├── images │ ├── hero1.jpg │ ├── hero2.jpg │ ├── hero3.jpg │ ├── hero4.png │ └── products │ │ ├── boot-ang1.png │ │ ├── boot-ang2.png │ │ ├── boot-core1.png │ │ ├── boot-core2.png │ │ ├── boot-redis1.png │ │ ├── glove-code1.png │ │ ├── glove-code2.png │ │ ├── glove-react1.png │ │ ├── glove-react2.png │ │ ├── hat-core1.png │ │ ├── hat-react1.png │ │ ├── hat-react2.png │ │ ├── sb-ang1.png │ │ ├── sb-ang2.png │ │ ├── sb-core1.png │ │ ├── sb-core2.png │ │ ├── sb-react1.png │ │ └── sb-ts1.png │ ├── index.html │ └── vite.svg ├── README.md ├── Restore.sln ├── client ├── .env.production ├── .gitignore ├── README.md ├── eslint.config.js ├── index.html ├── package-lock.json ├── package.json ├── public │ ├── images │ │ ├── hero1.jpg │ │ ├── hero2.jpg │ │ ├── hero3.jpg │ │ ├── hero4.png │ │ └── products │ │ │ ├── boot-ang1.png │ │ │ ├── boot-ang2.png │ │ │ ├── boot-core1.png │ │ │ ├── boot-core2.png │ │ │ ├── boot-redis1.png │ │ │ ├── glove-code1.png │ │ │ ├── glove-code2.png │ │ │ ├── glove-react1.png │ │ │ ├── glove-react2.png │ │ │ ├── hat-core1.png │ │ │ ├── hat-react1.png │ │ │ ├── hat-react2.png │ │ │ ├── sb-ang1.png │ │ │ ├── sb-ang2.png │ │ │ ├── sb-core1.png │ │ │ ├── sb-core2.png │ │ │ ├── sb-react1.png │ │ │ └── sb-ts1.png │ └── vite.svg ├── src │ ├── app │ │ ├── api │ │ │ └── baseApi.ts │ │ ├── errors │ │ │ ├── NotFound.tsx │ │ │ └── ServerError.tsx │ │ ├── layout │ │ │ ├── App.tsx │ │ │ ├── NavBar.tsx │ │ │ ├── UserMenu.tsx │ │ │ ├── styles.css │ │ │ └── uiSlice.ts │ │ ├── models │ │ │ ├── basket.ts │ │ │ ├── order.ts │ │ │ ├── pagination.ts │ │ │ ├── product.ts │ │ │ ├── productParams.ts │ │ │ └── user.ts │ │ ├── routes │ │ │ ├── RequireAuth.tsx │ │ │ └── Routes.tsx │ │ ├── shared │ │ │ └── components │ │ │ │ ├── AppDropzone.tsx │ │ │ │ ├── AppPagination.tsx │ │ │ │ ├── AppSelectInput.tsx │ │ │ │ ├── AppTextInput.tsx │ │ │ │ ├── CheckboxButtons.tsx │ │ │ │ ├── OrderSummary.tsx │ │ │ │ └── RadioButtonGroup.tsx │ │ └── store │ │ │ └── store.ts │ ├── assets │ │ └── react.svg │ ├── features │ │ ├── about │ │ │ ├── AboutPage.tsx │ │ │ └── errorApi.ts │ │ ├── account │ │ │ ├── LoginForm.tsx │ │ │ ├── RegisterForm.tsx │ │ │ └── accountApi.ts │ │ ├── admin │ │ │ ├── InventoryPage.tsx │ │ │ ├── ProductForm.tsx │ │ │ └── adminApi.ts │ │ ├── basket │ │ │ ├── BasketItem.tsx │ │ │ ├── BasketPage.tsx │ │ │ └── basketApi.ts │ │ ├── catalog │ │ │ ├── Catalog.tsx │ │ │ ├── Filters.tsx │ │ │ ├── ProductCard.tsx │ │ │ ├── ProductDetails.tsx │ │ │ ├── ProductList.tsx │ │ │ ├── Search.tsx │ │ │ ├── catalogApi.ts │ │ │ └── catalogSlice.ts │ │ ├── checkout │ │ │ ├── CheckoutPage.tsx │ │ │ ├── CheckoutStepper.tsx │ │ │ ├── CheckoutSuccess.tsx │ │ │ ├── Review.tsx │ │ │ └── checkoutApi.ts │ │ ├── contact │ │ │ ├── ContactPage.tsx │ │ │ └── counterReducer.ts │ │ ├── home │ │ │ └── HomePage.tsx │ │ └── orders │ │ │ ├── OrderDetailedPage.tsx │ │ │ ├── OrdersPage.tsx │ │ │ └── orderApi.ts │ ├── lib │ │ ├── hooks │ │ │ └── useBasket.ts │ │ ├── schemas │ │ │ ├── createProductSchema.ts │ │ │ ├── loginSchema.ts │ │ │ └── registerSchema.ts │ │ └── util.ts │ ├── main.tsx │ └── vite-env.d.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts └── docker-compose.yml /.github/workflows/main_restore-course.yml: -------------------------------------------------------------------------------- 1 | # Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy 2 | # More GitHub Actions for Azure: https://github.com/Azure/actions 3 | 4 | name: Build and deploy ASP.Net Core app to Azure Web App - restore-course 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | workflow_dispatch: 11 | 12 | jobs: 13 | build: 14 | runs-on: windows-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up node.js 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: '20' 23 | 24 | - name: Install deps and build client app 25 | run: | 26 | cd client 27 | npm install --legacy-peer-deps 28 | npm run build 29 | 30 | - name: Set up .NET Core 31 | uses: actions/setup-dotnet@v4 32 | with: 33 | dotnet-version: '9.x' 34 | 35 | - name: Build with dotnet 36 | run: dotnet build --configuration Release 37 | 38 | - name: dotnet publish 39 | run: dotnet publish -c Release -o "${{env.DOTNET_ROOT}}/myapp" 40 | 41 | - name: Upload artifact for deployment job 42 | uses: actions/upload-artifact@v4 43 | with: 44 | name: .net-app 45 | path: ${{env.DOTNET_ROOT}}/myapp 46 | 47 | deploy: 48 | runs-on: windows-latest 49 | needs: build 50 | environment: 51 | name: 'Production' 52 | url: ${{ steps.deploy-to-webapp.outputs.webapp-url }} 53 | permissions: 54 | id-token: write #This is required for requesting the JWT 55 | 56 | steps: 57 | - name: Download artifact from build job 58 | uses: actions/download-artifact@v4 59 | with: 60 | name: .net-app 61 | 62 | - name: Login to Azure 63 | uses: azure/login@v2 64 | with: 65 | client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_11DCF5EC3D664FBF8984253E7B82E718 }} 66 | tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_E284B8A61ADA459AB7614D8B05586068 }} 67 | subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_8F7F0F3886F448E5A405D8F1E698263D }} 68 | 69 | - name: Deploy to Azure Web App 70 | id: deploy-to-webapp 71 | uses: azure/webapps-deploy@v3 72 | with: 73 | app-name: 'restore-course' 74 | slot-name: 'Production' 75 | package: . 76 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "C#: API Debug", 9 | "type": "dotnet", 10 | "request": "launch", 11 | "projectPath": "${workspaceFolder}/API/API.csproj" 12 | }, 13 | { 14 | "name": ".NET Core Attach", 15 | "type": "coreclr", 16 | "request": "attach" 17 | } 18 | ] 19 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "sqltools.connections": [] 3 | } -------------------------------------------------------------------------------- /API/API.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | runtime; build; native; contentfiles; analyzers; buildtransitive 15 | all 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /API/Controllers/AccountController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using API.DTOs; 3 | using API.Entities; 4 | using Microsoft.AspNetCore.Authorization; 5 | using Microsoft.AspNetCore.Identity; 6 | using Microsoft.AspNetCore.Mvc; 7 | using Microsoft.EntityFrameworkCore; 8 | 9 | namespace API.Controllers; 10 | 11 | public class AccountController(SignInManager signInManager) : BaseApiController 12 | { 13 | [HttpPost("register")] 14 | public async Task RegisterUser(RegisterDto registerDto) 15 | { 16 | var user = new User{UserName = registerDto.Email, Email = registerDto.Email}; 17 | 18 | var result = await signInManager.UserManager.CreateAsync(user, registerDto.Password); 19 | 20 | if (!result.Succeeded) 21 | { 22 | foreach (var error in result.Errors) 23 | { 24 | ModelState.AddModelError(error.Code, error.Description); 25 | } 26 | 27 | return ValidationProblem(); 28 | } 29 | 30 | await signInManager.UserManager.AddToRoleAsync(user, "Member"); 31 | 32 | return Ok(); 33 | } 34 | 35 | [HttpGet("user-info")] 36 | public async Task GetUserInfo() 37 | { 38 | if (User.Identity?.IsAuthenticated == false) return NoContent(); 39 | 40 | var user = await signInManager.UserManager.GetUserAsync(User); 41 | 42 | if (user == null) return Unauthorized(); 43 | 44 | var roles = await signInManager.UserManager.GetRolesAsync(user); 45 | 46 | return Ok(new 47 | { 48 | user.Email, 49 | user.UserName, 50 | Roles = roles 51 | }); 52 | } 53 | 54 | [HttpPost("logout")] 55 | public async Task Logout() 56 | { 57 | await signInManager.SignOutAsync(); 58 | 59 | return NoContent(); 60 | } 61 | 62 | [Authorize] 63 | [HttpPost("address")] 64 | public async Task> CreateOrUpdateAddress(Address address) 65 | { 66 | var user = await signInManager.UserManager.Users 67 | .Include(x => x.Address) 68 | .FirstOrDefaultAsync(x => x.UserName == User.Identity!.Name); 69 | 70 | if (user == null) return Unauthorized(); 71 | 72 | user.Address = address; 73 | 74 | var result = await signInManager.UserManager.UpdateAsync(user); 75 | 76 | if (!result.Succeeded) return BadRequest("Problem updating user address"); 77 | 78 | return Ok(user.Address); 79 | } 80 | 81 | [Authorize] 82 | [HttpGet("address")] 83 | public async Task> GetSavedAddress() 84 | { 85 | var address = await signInManager.UserManager.Users 86 | .Where(x => x.UserName == User.Identity!.Name) 87 | .Select(x => x.Address) 88 | .FirstOrDefaultAsync(); 89 | 90 | if (address == null) return NoContent(); 91 | 92 | return address; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /API/Controllers/BaseApiController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | namespace API.Controllers 4 | { 5 | [Route("api/[controller]")] 6 | [ApiController] 7 | public class BaseApiController : ControllerBase 8 | { 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /API/Controllers/BasketController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using API.Data; 3 | using API.DTOs; 4 | using API.Entities; 5 | using API.Extensions; 6 | using API.Services; 7 | using Microsoft.AspNetCore.Mvc; 8 | using Microsoft.EntityFrameworkCore; 9 | 10 | namespace API.Controllers; 11 | 12 | public class BasketController(StoreContext context, 13 | DiscountService couponService, PaymentsService paymentsService) : BaseApiController 14 | { 15 | [HttpGet] 16 | public async Task> GetBasket() 17 | { 18 | var basket = await RetrieveBasket(); 19 | 20 | if (basket == null) return NoContent(); 21 | 22 | return basket.ToDto(); 23 | } 24 | 25 | [HttpPost] 26 | public async Task> AddItemToBasket(int productId, int quantity) 27 | { 28 | var basket = await RetrieveBasket(); 29 | 30 | basket ??= CreateBasket(); 31 | 32 | var product = await context.Products.FindAsync(productId); 33 | 34 | if (product == null) return BadRequest("Problem adding item to basket"); 35 | 36 | basket.AddItem(product, quantity); 37 | 38 | var result = await context.SaveChangesAsync() > 0; 39 | 40 | if (result) return CreatedAtAction(nameof(GetBasket), basket.ToDto()); 41 | 42 | return BadRequest("Problem updating basket"); 43 | } 44 | 45 | [HttpDelete] 46 | public async Task RemoveBasketItem(int productId, int quantity) 47 | { 48 | var basket = await RetrieveBasket(); 49 | 50 | if (basket == null) return BadRequest("Unable to retrieve basket"); 51 | 52 | basket.RemoveItem(productId, quantity); 53 | 54 | var result = await context.SaveChangesAsync() > 0; 55 | 56 | if (result) return Ok(); 57 | 58 | return BadRequest("Problem updating basket"); 59 | } 60 | 61 | [HttpPost("{code}")] 62 | public async Task> AddCouponCode(string code) 63 | { 64 | // get the basket 65 | var basket = await RetrieveBasket(); 66 | if (basket == null || string.IsNullOrEmpty(basket.ClientSecret)) return BadRequest("Unable to apply voucher"); 67 | 68 | // get the coupon 69 | var coupon = await couponService.GetCouponFromPromoCode(code); 70 | if (coupon == null) return BadRequest("Invalid coupon"); 71 | 72 | // update the basket with the coupon 73 | basket.Coupon = coupon; 74 | 75 | // update the payment intent 76 | var intent = await paymentsService.CreateOrUpdatePaymentIntent(basket); 77 | if (intent == null) return BadRequest("Problem applying coupon to basket"); 78 | 79 | // save changes and return BasketDto if successful 80 | var result = await context.SaveChangesAsync() > 0; 81 | 82 | if (result) return CreatedAtAction(nameof(GetBasket), basket.ToDto()); 83 | 84 | return BadRequest("Problem updating basket"); 85 | } 86 | 87 | [HttpDelete("remove-coupon")] 88 | public async Task RemoveCouponFromBasket() 89 | { 90 | // get the basket 91 | var basket = await RetrieveBasket(); 92 | if (basket == null || basket.Coupon == null || string.IsNullOrEmpty(basket.ClientSecret)) 93 | return BadRequest("Unable to update basket with coupon"); 94 | 95 | var intent = await paymentsService.CreateOrUpdatePaymentIntent(basket, true); 96 | if (intent == null) return BadRequest("Problem removing coupon from basket"); 97 | 98 | basket.Coupon = null; 99 | 100 | var result = await context.SaveChangesAsync() > 0; 101 | 102 | if (result) return Ok(); 103 | 104 | return BadRequest("Problem updating basket"); 105 | } 106 | 107 | private Basket CreateBasket() 108 | { 109 | var basketId = Guid.NewGuid().ToString(); 110 | var cookieOptions = new CookieOptions 111 | { 112 | IsEssential = true, 113 | Expires = DateTime.UtcNow.AddDays(30) 114 | }; 115 | Response.Cookies.Append("basketId", basketId, cookieOptions); 116 | var basket = new Basket { BasketId = basketId }; 117 | context.Baskets.Add(basket); 118 | return basket; 119 | } 120 | 121 | private async Task RetrieveBasket() 122 | { 123 | return await context.Baskets 124 | .Include(x => x.Items) 125 | .ThenInclude(x => x.Product) 126 | .FirstOrDefaultAsync(x => x.BasketId == Request.Cookies["basketId"]); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /API/Controllers/BuggyController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.AspNetCore.Mvc; 3 | 4 | namespace API.Controllers; 5 | 6 | public class BuggyController : BaseApiController 7 | { 8 | [HttpGet("not-found")] 9 | public IActionResult GetNotFound() 10 | { 11 | return NotFound(); 12 | } 13 | 14 | [HttpGet("bad-request")] 15 | public IActionResult GetBadRequest() 16 | { 17 | return BadRequest("This is not a good request"); 18 | } 19 | 20 | [HttpGet("unauthorized")] 21 | public IActionResult GetUnauthorised() 22 | { 23 | return Unauthorized(); 24 | } 25 | 26 | [HttpGet("validation-error")] 27 | public IActionResult GetValidationError() 28 | { 29 | ModelState.AddModelError("Problem1", "This is the first error"); 30 | ModelState.AddModelError("Problem2", "This is the second error"); 31 | return ValidationProblem(); 32 | } 33 | 34 | [HttpGet("server-error")] 35 | public IActionResult GetServerError() 36 | { 37 | throw new Exception("This is a server error"); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /API/Controllers/FallbackController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.AspNetCore.Authorization; 3 | using Microsoft.AspNetCore.Mvc; 4 | 5 | namespace API.Controllers; 6 | 7 | [AllowAnonymous] 8 | public class FallbackController : Controller 9 | { 10 | public IActionResult Index() 11 | { 12 | return PhysicalFile(Path.Combine(Directory.GetCurrentDirectory(), 13 | "wwwroot", "index.html"), "text/HTML"); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /API/Controllers/OrdersController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using API.Data; 3 | using API.DTOs; 4 | using API.Entities; 5 | using API.Entities.OrderAggregate; 6 | using API.Extensions; 7 | using API.Services; 8 | using Microsoft.AspNetCore.Authorization; 9 | using Microsoft.AspNetCore.Mvc; 10 | using Microsoft.EntityFrameworkCore; 11 | 12 | namespace API.Controllers; 13 | 14 | [Authorize] 15 | public class OrdersController(StoreContext context, DiscountService discountService) : BaseApiController 16 | { 17 | [HttpGet] 18 | public async Task>> GetOrders() 19 | { 20 | var orders = await context.Orders 21 | .ProjectToDto() 22 | .Where(x => x.BuyerEmail == User.GetUsername()) 23 | .ToListAsync(); 24 | 25 | return orders; 26 | } 27 | 28 | [HttpGet("{id:int}")] 29 | public async Task> GetOrderDetails(int id) 30 | { 31 | var order = await context.Orders 32 | .ProjectToDto() 33 | .Where(x => x.BuyerEmail == User.GetUsername() && id == x.Id) 34 | .FirstOrDefaultAsync(); 35 | 36 | if (order == null) return NotFound(); 37 | 38 | return order; 39 | } 40 | 41 | [HttpPost] 42 | public async Task> CreateOrder(CreateOrderDto orderDto) 43 | { 44 | var basket = await context.Baskets.GetBasketWithItems(Request.Cookies["basketId"]); 45 | 46 | if (basket == null || basket.Items.Count == 0 || string.IsNullOrEmpty(basket.PaymentIntentId)) 47 | return BadRequest("Basket is empty or not found"); 48 | 49 | var items = CreateOrderItems(basket.Items); 50 | if (items == null) return BadRequest("Some items out of stock"); 51 | 52 | var subtotal = items.Sum(x => x.Price * x.Quantity); 53 | var deliveryFee = CalculateDeliveryFee(subtotal); 54 | long discount = 0; 55 | 56 | if (basket.Coupon != null) 57 | { 58 | discount = await discountService.CalculateDiscountFromAmount(basket.Coupon, subtotal); 59 | } 60 | 61 | var order = await context.Orders 62 | .Include(x => x.OrderItems) 63 | .FirstOrDefaultAsync(x => x.PaymentIntentId == basket.PaymentIntentId); 64 | 65 | if (order == null) 66 | { 67 | order = new Order 68 | { 69 | OrderItems = items, 70 | BuyerEmail = User.GetUsername(), 71 | ShippingAddress = orderDto.ShippingAddress, 72 | DeliveryFee = deliveryFee, 73 | Subtotal = subtotal, 74 | Discount = discount, 75 | PaymentSummary = orderDto.PaymentSummary, 76 | PaymentIntentId = basket.PaymentIntentId 77 | }; 78 | 79 | context.Orders.Add(order); 80 | } 81 | else 82 | { 83 | order.OrderItems = items; 84 | } 85 | 86 | var result = await context.SaveChangesAsync() > 0; 87 | 88 | if (!result) return BadRequest("Problem creating order"); 89 | 90 | return CreatedAtAction(nameof(GetOrderDetails), new { id = order.Id }, order.ToDto()); 91 | } 92 | 93 | private long CalculateDeliveryFee(long subtotal) 94 | { 95 | return subtotal > 10000 ? 0 : 500; 96 | } 97 | 98 | private List? CreateOrderItems(List items) 99 | { 100 | var orderItems = new List(); 101 | 102 | foreach (var item in items) 103 | { 104 | if (item.Product.QuantityInStock < item.Quantity) 105 | return null; 106 | 107 | var orderItem = new OrderItem 108 | { 109 | ItemOrdered = new ProductItemOrdered 110 | { 111 | ProductId = item.ProductId, 112 | PictureUrl = item.Product.PictureUrl, 113 | Name = item.Product.Name 114 | }, 115 | Price = item.Product.Price, 116 | Quantity = item.Quantity 117 | }; 118 | orderItems.Add(orderItem); 119 | 120 | item.Product.QuantityInStock -= item.Quantity; 121 | } 122 | 123 | return orderItems; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /API/Controllers/PaymentsController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using API.Data; 3 | using API.DTOs; 4 | using API.Entities.OrderAggregate; 5 | using API.Extensions; 6 | using API.Services; 7 | using Microsoft.AspNetCore.Authorization; 8 | using Microsoft.AspNetCore.Mvc; 9 | using Microsoft.EntityFrameworkCore; 10 | using Stripe; 11 | 12 | namespace API.Controllers; 13 | 14 | public class PaymentsController(PaymentsService paymentsService, 15 | StoreContext context, IConfiguration config, ILogger logger) 16 | : BaseApiController 17 | { 18 | [Authorize] 19 | [HttpPost] 20 | public async Task> CreateOrUpdatePaymentIntent() 21 | { 22 | var basket = await context.Baskets.GetBasketWithItems(Request.Cookies["basketId"]); 23 | 24 | if (basket == null) return BadRequest("Problem with the basket"); 25 | 26 | var intent = await paymentsService.CreateOrUpdatePaymentIntent(basket); 27 | 28 | if (intent == null) return BadRequest("Problem creating payment intent"); 29 | 30 | basket.PaymentIntentId ??= intent.Id; 31 | basket.ClientSecret ??= intent.ClientSecret; 32 | 33 | if (context.ChangeTracker.HasChanges()) 34 | { 35 | var result = await context.SaveChangesAsync() > 0; 36 | 37 | if (!result) return BadRequest("Problem updating basket with intent"); 38 | } 39 | 40 | return basket.ToDto(); 41 | } 42 | 43 | [HttpPost("webhook")] 44 | public async Task StripeWebhook() 45 | { 46 | var json = await new StreamReader(Request.Body).ReadToEndAsync(); 47 | 48 | try 49 | { 50 | var stripeEvent = ConstructStripeEvent(json); 51 | 52 | if (stripeEvent.Data.Object is not PaymentIntent intent) 53 | { 54 | return BadRequest("Invalid event data"); 55 | } 56 | 57 | if (intent.Status == "succeeded") await HandlePaymentIntentSucceeded(intent); 58 | else await HandlePaymentIntentFailed(intent); 59 | 60 | return Ok(); 61 | } 62 | catch (StripeException ex) 63 | { 64 | logger.LogError(ex, "Stripe webhook error"); 65 | return StatusCode(StatusCodes.Status500InternalServerError, "Webhook error"); 66 | } 67 | catch (Exception ex) 68 | { 69 | logger.LogError(ex, "An expected error has occurred"); 70 | return StatusCode(StatusCodes.Status500InternalServerError, "Unexpected error"); 71 | } 72 | } 73 | 74 | private async Task HandlePaymentIntentFailed(PaymentIntent intent) 75 | { 76 | var order = await context.Orders 77 | .Include(x => x.OrderItems) 78 | .FirstOrDefaultAsync(x => x.PaymentIntentId == intent.Id) 79 | ?? throw new Exception("Order not found"); 80 | 81 | foreach (var item in order.OrderItems) 82 | { 83 | var productItem = await context.Products 84 | .FindAsync(item.ItemOrdered.ProductId) 85 | ?? throw new Exception("Problem updating order stock"); 86 | 87 | productItem.QuantityInStock += item.Quantity; 88 | } 89 | 90 | order.OrderStatus = OrderStatus.PaymentFailed; 91 | 92 | await context.SaveChangesAsync(); 93 | } 94 | 95 | private async Task HandlePaymentIntentSucceeded(PaymentIntent intent) 96 | { 97 | var order = await context.Orders 98 | .Include(x => x.OrderItems) 99 | .FirstOrDefaultAsync(x => x.PaymentIntentId == intent.Id) 100 | ?? throw new Exception("Order not found"); 101 | 102 | if (order.GetTotal() != intent.Amount) 103 | { 104 | order.OrderStatus = OrderStatus.PaymentMismatch; 105 | } 106 | else 107 | { 108 | order.OrderStatus = OrderStatus.PaymentReceived; 109 | } 110 | 111 | var basket = await context.Baskets.FirstOrDefaultAsync(x => 112 | x.PaymentIntentId == intent.Id); 113 | 114 | if (basket != null) context.Baskets.Remove(basket); 115 | 116 | await context.SaveChangesAsync(); 117 | } 118 | 119 | private Event ConstructStripeEvent(string json) 120 | { 121 | try 122 | { 123 | return EventUtility.ConstructEvent(json, 124 | Request.Headers["Stripe-Signature"], config["StripeSettings:WhSecret"]); 125 | } 126 | catch (Exception ex) 127 | { 128 | logger.LogError(ex, "Failed to construct stripe event"); 129 | throw new StripeException("Invalid signature"); 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /API/Controllers/ProductsController.cs: -------------------------------------------------------------------------------- 1 | using API.Data; 2 | using API.DTOs; 3 | using API.Entities; 4 | using API.Extensions; 5 | using API.RequestHelpers; 6 | using API.Services; 7 | using AutoMapper; 8 | using Microsoft.AspNetCore.Authorization; 9 | using Microsoft.AspNetCore.Mvc; 10 | using Microsoft.EntityFrameworkCore; 11 | 12 | namespace API.Controllers 13 | { 14 | public class ProductsController(StoreContext context, IMapper mapper, 15 | ImageService imageService) : BaseApiController 16 | { 17 | [HttpGet] 18 | public async Task>> GetProducts( 19 | [FromQuery] ProductParams productParams) 20 | { 21 | var query = context.Products 22 | .Sort(productParams.OrderBy) 23 | .Search(productParams.SearchTerm) 24 | .Filter(productParams.Brands, productParams.Types) 25 | .AsQueryable(); 26 | 27 | var products = await PagedList.ToPagedList(query, 28 | productParams.PageNumber, productParams.PageSize); 29 | 30 | Response.AddPaginationHeader(products.Metadata); 31 | 32 | return products; 33 | } 34 | 35 | [HttpGet("{id}")] // api/products/2 36 | public async Task> GetProduct(int id) 37 | { 38 | var product = await context.Products.FindAsync(id); 39 | 40 | if (product == null) return NotFound(); 41 | 42 | return product; 43 | } 44 | 45 | [HttpGet("filters")] 46 | public async Task GetFilters() 47 | { 48 | var brands = await context.Products.Select(x => x.Brand).Distinct().ToListAsync(); 49 | var types = await context.Products.Select(x => x.Type).Distinct().ToListAsync(); 50 | 51 | return Ok(new { brands, types }); 52 | } 53 | 54 | [Authorize(Roles = "Admin")] 55 | [HttpPost] 56 | public async Task> CreateProduct(CreateProductDto productDto) 57 | { 58 | var product = mapper.Map(productDto); 59 | 60 | if (productDto.File != null) 61 | { 62 | var imageResult = await imageService.AddImageAsync(productDto.File); 63 | 64 | if (imageResult.Error != null) 65 | { 66 | return BadRequest(imageResult.Error.Message); 67 | } 68 | 69 | product.PictureUrl = imageResult.SecureUrl.AbsoluteUri; 70 | product.PublicId = imageResult.PublicId; 71 | } 72 | 73 | context.Products.Add(product); 74 | 75 | var result = await context.SaveChangesAsync() > 0; 76 | 77 | if (result) return CreatedAtAction(nameof(GetProduct), new { Id = product.Id }, product); 78 | 79 | return BadRequest("Problem creating new procuct"); 80 | } 81 | 82 | [Authorize(Roles = "Admin")] 83 | [HttpPut] 84 | public async Task UpdateProduct(UpdateProductDto updateProductDto) 85 | { 86 | var product = await context.Products.FindAsync(updateProductDto.Id); 87 | 88 | if (product == null) return NotFound(); 89 | 90 | mapper.Map(updateProductDto, product); 91 | 92 | if (updateProductDto.File != null) 93 | { 94 | var imageResult = await imageService.AddImageAsync(updateProductDto.File); 95 | 96 | if (imageResult.Error != null) 97 | return BadRequest(imageResult.Error.Message); 98 | 99 | if (!string.IsNullOrEmpty(product.PublicId)) 100 | await imageService.DeleteImageAsync(product.PublicId); 101 | 102 | product.PictureUrl = imageResult.SecureUrl.AbsoluteUri; 103 | product.PublicId = imageResult.PublicId; 104 | } 105 | 106 | var result = await context.SaveChangesAsync() > 0; 107 | 108 | if (result) return NoContent(); 109 | 110 | return BadRequest("Problem updating product"); 111 | } 112 | 113 | [Authorize(Roles = "Admin")] 114 | [HttpDelete("{id:int}")] 115 | public async Task DeleteProduct(int id) 116 | { 117 | var product = await context.Products.FindAsync(id); 118 | 119 | if (product == null) return NotFound(); 120 | 121 | if (!string.IsNullOrEmpty(product.PublicId)) 122 | await imageService.DeleteImageAsync(product.PublicId); 123 | 124 | context.Products.Remove(product); 125 | 126 | var result = await context.SaveChangesAsync() > 0; 127 | 128 | if (result) return Ok(); 129 | 130 | return BadRequest("Problem deleting the product"); 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /API/Controllers/WeatherForecastController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | namespace API.Controllers; 4 | 5 | public class WeatherForecastController : BaseApiController 6 | { 7 | private static readonly string[] Summaries = new[] 8 | { 9 | "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" 10 | }; 11 | 12 | private readonly ILogger _logger; 13 | 14 | public WeatherForecastController(ILogger logger) 15 | { 16 | _logger = logger; 17 | } 18 | 19 | [HttpGet(Name = "GetWeatherForecast")] 20 | public IEnumerable Get() 21 | { 22 | return Enumerable.Range(1, 5).Select(index => new WeatherForecast 23 | { 24 | Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), 25 | TemperatureC = Random.Shared.Next(-20, 55), 26 | Summary = Summaries[Random.Shared.Next(Summaries.Length)] 27 | }) 28 | .ToArray(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /API/DTOs/BasketDto.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using API.Entities; 3 | 4 | namespace API.DTOs; 5 | 6 | public class BasketDto 7 | { 8 | public required string BasketId { get; set; } 9 | public List Items { get; set; } = []; 10 | public string? ClientSecret { get; set; } 11 | public AppCoupon? Coupon { get; set; } 12 | } 13 | -------------------------------------------------------------------------------- /API/DTOs/BasketItemDto.cs: -------------------------------------------------------------------------------- 1 | namespace API.DTOs; 2 | 3 | public class BasketItemDto 4 | { 5 | public int ProductId { get; set; } 6 | public required string Name { get; set; } 7 | public long Price { get; set; } 8 | public required string PictureUrl { get; set; } 9 | public required string Brand { get; set; } 10 | public required string Type { get; set; } 11 | public int Quantity { get; set; } 12 | } -------------------------------------------------------------------------------- /API/DTOs/CreateOrderDto.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using API.Entities.OrderAggregate; 3 | 4 | namespace API.DTOs; 5 | 6 | public class CreateOrderDto 7 | { 8 | public required ShippingAddress ShippingAddress { get; set; } 9 | public required PaymentSummary PaymentSummary { get; set; } 10 | } 11 | -------------------------------------------------------------------------------- /API/DTOs/CreateProductDto.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.ComponentModel.DataAnnotations; 3 | 4 | namespace API.DTOs; 5 | 6 | public class CreateProductDto 7 | { 8 | [Required] 9 | public string Name { get; set; } = string.Empty; 10 | 11 | [Required] 12 | public string Description { get; set; } = string.Empty; 13 | 14 | [Required] 15 | [Range(100, double.PositiveInfinity)] 16 | public long Price { get; set; } 17 | 18 | [Required] 19 | public IFormFile File { get; set; } = null!; 20 | 21 | [Required] 22 | public required string Type { get; set; } 23 | 24 | [Required] 25 | public required string Brand { get; set; } 26 | 27 | [Required] 28 | [Range(0, 200)] 29 | public int QuantityInStock { get; set; } 30 | } 31 | -------------------------------------------------------------------------------- /API/DTOs/OrderDto.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using API.Entities.OrderAggregate; 3 | 4 | namespace API.DTOs; 5 | 6 | public class OrderDto 7 | { 8 | public int Id { get; set; } 9 | public required string BuyerEmail { get; set; } 10 | public required ShippingAddress ShippingAddress { get; set; } 11 | public DateTime OrderDate { get; set; } 12 | public List OrderItems { get; set; } = []; 13 | public long Subtotal { get; set; } 14 | public long DeliveryFee { get; set; } 15 | public long Discount { get; set; } 16 | public long Total { get; set; } 17 | public required string OrderStatus { get; set; } 18 | public required PaymentSummary PaymentSummary { get; set; } 19 | } 20 | -------------------------------------------------------------------------------- /API/DTOs/OrderItemDto.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace API.DTOs; 4 | 5 | public class OrderItemDto 6 | { 7 | public int ProductId { get; set; } 8 | public required string Name { get; set; } 9 | public required string PictureUrl { get; set; } 10 | public long Price { get; set; } 11 | public int Quantity { get; set; } 12 | } 13 | -------------------------------------------------------------------------------- /API/DTOs/RegisterDto.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.ComponentModel.DataAnnotations; 3 | 4 | namespace API.DTOs; 5 | 6 | public class RegisterDto 7 | { 8 | [Required] 9 | public string Email { get; set; } = string.Empty; 10 | public required string Password { get; set; } 11 | } 12 | -------------------------------------------------------------------------------- /API/DTOs/UpdateProductDto.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.ComponentModel.DataAnnotations; 3 | 4 | namespace API.DTOs; 5 | 6 | public class UpdateProductDto 7 | { 8 | public int Id { get; set; } 9 | 10 | [Required] 11 | public string Name { get; set; } = string.Empty; 12 | 13 | [Required] 14 | public string Description { get; set; } = string.Empty; 15 | 16 | [Required] 17 | [Range(100, double.PositiveInfinity)] 18 | public long Price { get; set; } 19 | 20 | public IFormFile? File { get; set; } 21 | 22 | [Required] 23 | public required string Type { get; set; } 24 | 25 | [Required] 26 | public required string Brand { get; set; } 27 | 28 | [Required] 29 | [Range(0, 200)] 30 | public int QuantityInStock { get; set; } 31 | } 32 | -------------------------------------------------------------------------------- /API/Data/Migrations/20241210024925_PublicIdAdded.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | #nullable disable 4 | 5 | namespace API.Data.Migrations 6 | { 7 | /// 8 | public partial class PublicIdAdded : Migration 9 | { 10 | /// 11 | protected override void Up(MigrationBuilder migrationBuilder) 12 | { 13 | migrationBuilder.AddColumn( 14 | name: "PublicId", 15 | table: "Products", 16 | type: "nvarchar(max)", 17 | nullable: true); 18 | } 19 | 20 | /// 21 | protected override void Down(MigrationBuilder migrationBuilder) 22 | { 23 | migrationBuilder.DropColumn( 24 | name: "PublicId", 25 | table: "Products"); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /API/Data/Migrations/20241213031915_CouponAdded.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | #nullable disable 4 | 5 | namespace API.Data.Migrations 6 | { 7 | /// 8 | public partial class CouponAdded : Migration 9 | { 10 | /// 11 | protected override void Up(MigrationBuilder migrationBuilder) 12 | { 13 | migrationBuilder.AddColumn( 14 | name: "Coupon_AmountOff", 15 | table: "Baskets", 16 | type: "bigint", 17 | nullable: true); 18 | 19 | migrationBuilder.AddColumn( 20 | name: "Coupon_CouponId", 21 | table: "Baskets", 22 | type: "nvarchar(max)", 23 | nullable: true); 24 | 25 | migrationBuilder.AddColumn( 26 | name: "Coupon_Name", 27 | table: "Baskets", 28 | type: "nvarchar(max)", 29 | nullable: true); 30 | 31 | migrationBuilder.AddColumn( 32 | name: "Coupon_PercentOff", 33 | table: "Baskets", 34 | type: "decimal(5,2)", 35 | precision: 5, 36 | scale: 2, 37 | nullable: true); 38 | 39 | migrationBuilder.AddColumn( 40 | name: "Coupon_PromotionCode", 41 | table: "Baskets", 42 | type: "nvarchar(max)", 43 | nullable: true); 44 | } 45 | 46 | /// 47 | protected override void Down(MigrationBuilder migrationBuilder) 48 | { 49 | migrationBuilder.DropColumn( 50 | name: "Coupon_AmountOff", 51 | table: "Baskets"); 52 | 53 | migrationBuilder.DropColumn( 54 | name: "Coupon_CouponId", 55 | table: "Baskets"); 56 | 57 | migrationBuilder.DropColumn( 58 | name: "Coupon_Name", 59 | table: "Baskets"); 60 | 61 | migrationBuilder.DropColumn( 62 | name: "Coupon_PercentOff", 63 | table: "Baskets"); 64 | 65 | migrationBuilder.DropColumn( 66 | name: "Coupon_PromotionCode", 67 | table: "Baskets"); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /API/Data/StoreContext.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using API.Entities; 3 | using API.Entities.OrderAggregate; 4 | using Microsoft.AspNetCore.Identity; 5 | using Microsoft.AspNetCore.Identity.EntityFrameworkCore; 6 | using Microsoft.EntityFrameworkCore; 7 | 8 | namespace API.Data; 9 | 10 | public class StoreContext(DbContextOptions options) : IdentityDbContext(options) 11 | { 12 | public required DbSet Products { get; set; } 13 | public required DbSet Baskets { get; set; } 14 | public required DbSet Orders { get; set; } 15 | 16 | protected override void OnModelCreating(ModelBuilder builder) 17 | { 18 | base.OnModelCreating(builder); 19 | 20 | builder.Entity() 21 | .HasData( 22 | new IdentityRole {Id = "e069461a-10cf-4abf-9930-d070b2a7e40f", Name = "Member", NormalizedName = "MEMBER"}, 23 | new IdentityRole {Id = "ed2e9149-fa53-484c-a93f-bd33f9e9fcf6", Name = "Admin", NormalizedName = "ADMIN"} 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /API/Entities/Address.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Json.Serialization; 3 | 4 | namespace API.Entities; 5 | 6 | public class Address 7 | { 8 | [JsonIgnore] 9 | public int Id { get; set; } 10 | public required string Name { get; set; } 11 | public required string Line1 { get; set; } 12 | public string? Line2 { get; set; } 13 | public required string City { get; set; } 14 | public required string State { get; set; } 15 | 16 | [JsonPropertyName("postal_code")] 17 | public required string PostalCode { get; set; } 18 | public required string Country { get; set; } 19 | } 20 | -------------------------------------------------------------------------------- /API/Entities/AppCoupon.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | 3 | namespace API.Entities; 4 | 5 | [Owned] 6 | public class AppCoupon 7 | { 8 | public required string Name { get; set; } 9 | public long? AmountOff { get; set; } 10 | 11 | [Precision(5,2)] 12 | public decimal? PercentOff { get; set; } 13 | public required string PromotionCode { get; set; } 14 | public required string CouponId { get; set; } 15 | } 16 | -------------------------------------------------------------------------------- /API/Entities/Basket.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace API.Entities; 4 | 5 | public class Basket 6 | { 7 | public int Id { get; set; } 8 | public required string BasketId { get; set; } 9 | public List Items { get; set; } = []; 10 | public string? ClientSecret { get; set; } 11 | public string? PaymentIntentId { get; set; } 12 | public AppCoupon? Coupon { get; set; } 13 | 14 | public void AddItem(Product product, int quantity) 15 | { 16 | if (product == null) ArgumentNullException.ThrowIfNull(product); 17 | if (quantity <= 0) throw new ArgumentException("Quantity should be greater than zero", 18 | nameof(quantity)); 19 | 20 | var existingItem = FindItem(product.Id); 21 | 22 | if (existingItem == null) 23 | { 24 | Items.Add(new BasketItem 25 | { 26 | Product = product, 27 | Quantity = quantity 28 | }); 29 | } 30 | else 31 | { 32 | existingItem.Quantity += quantity; 33 | } 34 | } 35 | 36 | public void RemoveItem(int productId, int quantity) 37 | { 38 | if (quantity <= 0) throw new ArgumentException("Quantity should be greater than zero", 39 | nameof(quantity)); 40 | 41 | var item = FindItem(productId); 42 | if (item == null) return; 43 | 44 | item.Quantity -= quantity; 45 | if (item.Quantity <= 0) Items.Remove(item); 46 | } 47 | 48 | private BasketItem? FindItem(int productId) 49 | { 50 | return Items.FirstOrDefault(item => item.ProductId == productId); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /API/Entities/BasketItem.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations.Schema; 2 | 3 | namespace API.Entities; 4 | 5 | [Table("BasketItems")] 6 | public class BasketItem 7 | { 8 | public int Id { get; set; } 9 | public int Quantity { get; set; } 10 | 11 | // navigation properties 12 | public int ProductId { get; set; } 13 | public required Product Product { get; set; } 14 | 15 | public int BasketId { get; set; } 16 | public Basket Basket { get; set; } = null!; 17 | } -------------------------------------------------------------------------------- /API/Entities/OrderAggregate/Order.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace API.Entities.OrderAggregate; 4 | 5 | public class Order 6 | { 7 | public int Id { get; set; } 8 | public required string BuyerEmail { get; set; } 9 | public required ShippingAddress ShippingAddress { get; set; } 10 | public DateTime OrderDate { get; set; } = DateTime.UtcNow; 11 | public List OrderItems { get; set; } = []; 12 | public long Subtotal { get; set; } 13 | public long DeliveryFee { get; set; } 14 | public long Discount { get; set; } 15 | public required string PaymentIntentId { get; set; } 16 | public OrderStatus OrderStatus { get; set; } = OrderStatus.Pending; 17 | public required PaymentSummary PaymentSummary { get; set; } 18 | 19 | public long GetTotal() 20 | { 21 | return Subtotal + DeliveryFee - Discount; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /API/Entities/OrderAggregate/OrderItem.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace API.Entities.OrderAggregate; 4 | 5 | public class OrderItem 6 | { 7 | public int Id { get; set; } 8 | public required ProductItemOrdered ItemOrdered { get; set; } 9 | public long Price { get; set; } 10 | public int Quantity { get; set; } 11 | } 12 | -------------------------------------------------------------------------------- /API/Entities/OrderAggregate/OrderStatus.cs: -------------------------------------------------------------------------------- 1 | namespace API.Entities.OrderAggregate; 2 | 3 | public enum OrderStatus 4 | { 5 | Pending, 6 | PaymentReceived, 7 | PaymentFailed, 8 | PaymentMismatch 9 | } 10 | -------------------------------------------------------------------------------- /API/Entities/OrderAggregate/PaymentSummary.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Json.Serialization; 3 | using Microsoft.EntityFrameworkCore; 4 | 5 | namespace API.Entities.OrderAggregate; 6 | 7 | [Owned] 8 | public class PaymentSummary 9 | { 10 | public int Last4 { get; set; } 11 | public required string Brand { get; set; } 12 | 13 | [JsonPropertyName("exp_month")] 14 | public int ExpMonth { get; set; } 15 | 16 | [JsonPropertyName("exp_year")] 17 | public int ExpYear { get; set; } 18 | } 19 | -------------------------------------------------------------------------------- /API/Entities/OrderAggregate/ProductItemOrdered.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore; 3 | 4 | namespace API.Entities.OrderAggregate; 5 | 6 | [Owned] 7 | public class ProductItemOrdered 8 | { 9 | public int ProductId { get; set; } 10 | public required string Name { get; set; } 11 | public required string PictureUrl { get; set; } 12 | } 13 | -------------------------------------------------------------------------------- /API/Entities/OrderAggregate/ShippingAddress.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Json.Serialization; 3 | using Microsoft.EntityFrameworkCore; 4 | 5 | namespace API.Entities.OrderAggregate; 6 | 7 | [Owned] 8 | public class ShippingAddress 9 | { 10 | public required string Name { get; set; } 11 | public required string Line1 { get; set; } 12 | public string? Line2 { get; set; } 13 | public required string City { get; set; } 14 | public required string State { get; set; } 15 | 16 | [JsonPropertyName("postal_code")] 17 | public required string PostalCode { get; set; } 18 | public required string Country { get; set; } 19 | } 20 | -------------------------------------------------------------------------------- /API/Entities/Product.cs: -------------------------------------------------------------------------------- 1 | namespace API.Entities; 2 | 3 | public class Product 4 | { 5 | public int Id { get; set; } 6 | public required string Name { get; set; } 7 | public required string Description { get; set; } 8 | public long Price { get; set; } 9 | public required string PictureUrl { get; set; } 10 | public required string Type { get; set; } 11 | public required string Brand { get; set; } 12 | public int QuantityInStock { get; set; } 13 | public string? PublicId { get; set; } 14 | } 15 | -------------------------------------------------------------------------------- /API/Entities/User.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.AspNetCore.Identity; 3 | 4 | namespace API.Entities; 5 | 6 | public class User : IdentityUser 7 | { 8 | public int? AddressId { get; set; } 9 | public Address? Address { get; set; } 10 | } 11 | -------------------------------------------------------------------------------- /API/Extensions/BasketExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using API.DTOs; 3 | using API.Entities; 4 | using Microsoft.EntityFrameworkCore; 5 | 6 | namespace API.Extensions; 7 | 8 | public static class BasketExtensions 9 | { 10 | public static BasketDto ToDto(this Basket basket) 11 | { 12 | return new BasketDto 13 | { 14 | BasketId = basket.BasketId, 15 | ClientSecret = basket.ClientSecret, 16 | Coupon = basket.Coupon, 17 | Items = basket.Items.Select(x => new BasketItemDto 18 | { 19 | ProductId = x.ProductId, 20 | Name = x.Product.Name, 21 | Price = x.Product.Price, 22 | Brand = x.Product.Brand, 23 | Type = x.Product.Type, 24 | PictureUrl = x.Product.PictureUrl, 25 | Quantity = x.Quantity 26 | }).ToList() 27 | }; 28 | } 29 | 30 | public static async Task GetBasketWithItems(this IQueryable query, 31 | string? basketId) 32 | { 33 | return await query 34 | .Include(x => x.Items) 35 | .ThenInclude(x => x.Product) 36 | .FirstOrDefaultAsync(x => x.BasketId == basketId) 37 | ?? throw new Exception("Cannot get basket"); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /API/Extensions/ClaimsPrincipalExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Security.Claims; 3 | 4 | namespace API.Extensions; 5 | 6 | public static class ClaimsPrincipalExtensions 7 | { 8 | public static string GetUsername(this ClaimsPrincipal user) 9 | { 10 | return user.Identity?.Name ?? throw new UnauthorizedAccessException(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /API/Extensions/HttpExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Json; 3 | using API.RequestHelpers; 4 | using Microsoft.Net.Http.Headers; 5 | 6 | namespace API.Extensions; 7 | 8 | public static class HttpExtensions 9 | { 10 | public static void AddPaginationHeader(this HttpResponse response, PaginationMetadata metadata) 11 | { 12 | var options = new JsonSerializerOptions{PropertyNamingPolicy = JsonNamingPolicy.CamelCase}; 13 | 14 | response.Headers.Append("Pagination", JsonSerializer.Serialize(metadata, options)); 15 | response.Headers.Append(HeaderNames.AccessControlExposeHeaders, "Pagination"); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /API/Extensions/OrderExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using API.DTOs; 3 | using API.Entities.OrderAggregate; 4 | using Microsoft.EntityFrameworkCore; 5 | 6 | namespace API.Extensions; 7 | 8 | public static class OrderExtensions 9 | { 10 | public static IQueryable ProjectToDto(this IQueryable query) 11 | { 12 | return query.Select(order => new OrderDto 13 | { 14 | Id = order.Id, 15 | BuyerEmail = order.BuyerEmail, 16 | OrderDate = order.OrderDate, 17 | ShippingAddress = order.ShippingAddress, 18 | PaymentSummary = order.PaymentSummary, 19 | DeliveryFee = order.DeliveryFee, 20 | Subtotal = order.Subtotal, 21 | Discount = order.Discount, 22 | OrderStatus = order.OrderStatus.ToString(), 23 | Total = order.GetTotal(), 24 | OrderItems = order.OrderItems.Select(item => new OrderItemDto 25 | { 26 | ProductId = item.ItemOrdered.ProductId, 27 | Name = item.ItemOrdered.Name, 28 | PictureUrl = item.ItemOrdered.PictureUrl, 29 | Price = item.Price, 30 | Quantity = item.Quantity 31 | }).ToList() 32 | }).AsNoTracking(); 33 | } 34 | 35 | public static OrderDto ToDto(this Order order) 36 | { 37 | return new OrderDto 38 | { 39 | Id = order.Id, 40 | BuyerEmail = order.BuyerEmail, 41 | OrderDate = order.OrderDate, 42 | ShippingAddress = order.ShippingAddress, 43 | PaymentSummary = order.PaymentSummary, 44 | DeliveryFee = order.DeliveryFee, 45 | Subtotal = order.Subtotal, 46 | Discount = order.Discount, 47 | OrderStatus = order.OrderStatus.ToString(), 48 | Total = order.GetTotal(), 49 | OrderItems = order.OrderItems.Select(item => new OrderItemDto 50 | { 51 | ProductId = item.ItemOrdered.ProductId, 52 | Name = item.ItemOrdered.Name, 53 | PictureUrl = item.ItemOrdered.PictureUrl, 54 | Price = item.Price, 55 | Quantity = item.Quantity 56 | }).ToList() 57 | }; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /API/Extensions/ProductExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using API.Entities; 3 | 4 | namespace API.Extensions; 5 | 6 | public static class ProductExtensions 7 | { 8 | public static IQueryable Sort(this IQueryable query, string? orderBy) 9 | { 10 | query = orderBy switch 11 | { 12 | "price" => query.OrderBy(x => x.Price), 13 | "priceDesc" => query.OrderByDescending(x => x.Price), 14 | _ => query.OrderBy(x => x.Name) 15 | }; 16 | 17 | return query; 18 | } 19 | 20 | public static IQueryable Search(this IQueryable query, string? searchTerm) 21 | { 22 | if (string.IsNullOrEmpty(searchTerm)) return query; 23 | 24 | var lowerCaseSearchTerm = searchTerm.Trim().ToLower(); 25 | 26 | return query.Where(x => x.Name.ToLower().Contains(lowerCaseSearchTerm)); 27 | } 28 | 29 | public static IQueryable Filter(this IQueryable query, 30 | string? brands, string? types) { 31 | var brandList = new List(); 32 | var typeList = new List(); 33 | 34 | if (!string.IsNullOrEmpty(brands)) 35 | { 36 | brandList.AddRange([.. brands.ToLower().Split(",")]); 37 | } 38 | 39 | if (!string.IsNullOrEmpty(types)) 40 | { 41 | typeList.AddRange([.. types.ToLower().Split(",")]); 42 | } 43 | 44 | query = query.Where(x => brandList.Count == 0 || brandList.Contains(x.Brand.ToLower())); 45 | query = query.Where(x => typeList.Count == 0 || typeList.Contains(x.Type.ToLower())); 46 | 47 | return query; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /API/Middleware/ExceptionMiddleware.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net; 3 | using System.Text.Json; 4 | using Microsoft.AspNetCore.Mvc; 5 | 6 | namespace API.Middleware; 7 | 8 | public class ExceptionMiddleware(IHostEnvironment env, ILogger logger) 9 | : IMiddleware 10 | { 11 | public async Task InvokeAsync(HttpContext context, RequestDelegate next) 12 | { 13 | try 14 | { 15 | await next(context); 16 | } 17 | catch (Exception ex) 18 | { 19 | await HandleException(context, ex); 20 | } 21 | } 22 | 23 | private async Task HandleException(HttpContext context, Exception ex) 24 | { 25 | logger.LogError(ex, ex.Message); 26 | context.Response.ContentType = "application/json"; 27 | context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; 28 | 29 | var response = new ProblemDetails 30 | { 31 | Status = 500, 32 | Detail = env.IsDevelopment() 33 | ? ex.StackTrace?.ToString() 34 | : null, 35 | Title = ex.Message 36 | }; 37 | 38 | var options = new JsonSerializerOptions 39 | {PropertyNamingPolicy = JsonNamingPolicy.CamelCase}; 40 | 41 | var json = JsonSerializer.Serialize(response, options); 42 | 43 | await context.Response.WriteAsync(json); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /API/Program.cs: -------------------------------------------------------------------------------- 1 | using API.Data; 2 | using API.Entities; 3 | using API.Middleware; 4 | using API.RequestHelpers; 5 | using API.Services; 6 | using Microsoft.AspNetCore.Identity; 7 | using Microsoft.EntityFrameworkCore; 8 | 9 | var builder = WebApplication.CreateBuilder(args); 10 | 11 | // Add services to the container. 12 | builder.Services.Configure(builder.Configuration.GetSection("Cloudinary")); 13 | builder.Services.AddControllers(); 14 | builder.Services.AddDbContext(opt => 15 | { 16 | opt.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")); 17 | }); 18 | builder.Services.AddCors(); 19 | builder.Services.AddAutoMapper(AppDomain.CurrentDomain.GetAssemblies()); 20 | builder.Services.AddTransient(); 21 | builder.Services.AddScoped(); 22 | builder.Services.AddScoped(); 23 | builder.Services.AddScoped(); 24 | builder.Services.AddIdentityApiEndpoints(opt => 25 | { 26 | opt.User.RequireUniqueEmail = true; 27 | }) 28 | .AddRoles() 29 | .AddEntityFrameworkStores(); 30 | 31 | var app = builder.Build(); 32 | 33 | // Configure the HTTP request pipeline. 34 | app.UseMiddleware(); 35 | 36 | app.UseDefaultFiles(); 37 | app.UseStaticFiles(); 38 | 39 | app.UseCors(opt => 40 | { 41 | opt.AllowAnyHeader().AllowAnyMethod().AllowCredentials().WithOrigins("https://localhost:3000"); 42 | }); 43 | 44 | app.UseAuthentication(); 45 | app.UseAuthorization(); 46 | 47 | app.MapControllers(); 48 | app.MapGroup("api").MapIdentityApi(); // api/login 49 | app.MapFallbackToController("Index", "Fallback"); 50 | 51 | await DbInitializer.InitDb(app); 52 | 53 | app.Run(); 54 | -------------------------------------------------------------------------------- /API/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "https": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": true, 7 | "launchBrowser": false, 8 | "applicationUrl": "https://localhost:5001;", 9 | "environmentVariables": { 10 | "ASPNETCORE_ENVIRONMENT": "Development" 11 | } 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /API/RequestHelpers/CloudinarySettings.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace API.RequestHelpers; 4 | 5 | public class CloudinarySettings 6 | { 7 | public required string CloudName { get; set; } 8 | public required string ApiKey { get; set; } 9 | public required string ApiSecret { get; set; } 10 | } 11 | -------------------------------------------------------------------------------- /API/RequestHelpers/MappingProfiles.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using API.DTOs; 3 | using API.Entities; 4 | using AutoMapper; 5 | 6 | namespace API.RequestHelpers; 7 | 8 | public class MappingProfiles : Profile 9 | { 10 | public MappingProfiles() 11 | { 12 | CreateMap(); 13 | CreateMap(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /API/RequestHelpers/PagedList.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore; 3 | 4 | namespace API.RequestHelpers; 5 | 6 | public class PagedList : List 7 | { 8 | public PagedList(List items, int count, int pageNumber, int pageSize) 9 | { 10 | Metadata = new PaginationMetadata 11 | { 12 | TotalCount = count, 13 | PageSize = pageSize, 14 | CurrentPage = pageNumber, 15 | TotalPages = (int)Math.Ceiling(count / (double)pageSize) 16 | }; 17 | AddRange(items); 18 | } 19 | 20 | public PaginationMetadata Metadata { get; set; } 21 | 22 | public static async Task> ToPagedList(IQueryable query, 23 | int pageNumber, int pageSize) 24 | { 25 | var count = await query.CountAsync(); 26 | var items = await query.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToListAsync(); 27 | return new PagedList(items, count, pageNumber, pageSize); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /API/RequestHelpers/PaginationMetadata.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace API.RequestHelpers; 4 | 5 | public class PaginationMetadata 6 | { 7 | public int TotalCount { get; set; } 8 | public int PageSize { get; set; } 9 | public int CurrentPage { get; set; } 10 | public int TotalPages { get; set; } 11 | } 12 | -------------------------------------------------------------------------------- /API/RequestHelpers/PaginationParams.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace API.RequestHelpers; 4 | 5 | public class PaginationParams 6 | { 7 | private const int MaxPageSize = 50; 8 | public int PageNumber { get; set; } = 1; 9 | private int _pageSize = 8; 10 | public int PageSize 11 | { 12 | get => _pageSize; 13 | set => _pageSize = value > MaxPageSize ? MaxPageSize : value; 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /API/RequestHelpers/ProductParams.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace API.RequestHelpers; 4 | 5 | public class ProductParams : PaginationParams 6 | { 7 | public string? OrderBy { get; set; } 8 | public string? SearchTerm { get; set; } 9 | public string? Brands { get; set; } 10 | public string? Types { get; set; } 11 | } 12 | -------------------------------------------------------------------------------- /API/Services/DiscountService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using API.Entities; 3 | using Stripe; 4 | 5 | namespace API.Services; 6 | 7 | public class DiscountService 8 | { 9 | public DiscountService(IConfiguration config) 10 | { 11 | StripeConfiguration.ApiKey = config["StripeSettings:SecretKey"]; 12 | } 13 | 14 | public async Task GetCouponFromPromoCode(string code) 15 | { 16 | var promotionService = new PromotionCodeService(); 17 | 18 | var options = new PromotionCodeListOptions 19 | { 20 | Code = code 21 | }; 22 | 23 | var promotionCodes = await promotionService.ListAsync(options); 24 | 25 | var promotionCode = promotionCodes.FirstOrDefault(); 26 | 27 | if (promotionCode != null && promotionCode.Coupon != null) 28 | { 29 | return new AppCoupon 30 | { 31 | Name = promotionCode.Coupon.Name, 32 | AmountOff = promotionCode.Coupon.AmountOff, 33 | PercentOff = promotionCode.Coupon.PercentOff, 34 | CouponId = promotionCode.Coupon.Id, 35 | PromotionCode = promotionCode.Code 36 | }; 37 | } 38 | 39 | return null; 40 | } 41 | 42 | public async Task CalculateDiscountFromAmount(AppCoupon appCoupon, long amount, 43 | bool removeDiscount = false) 44 | { 45 | var couponService = new CouponService(); 46 | 47 | var coupon = await couponService.GetAsync(appCoupon.CouponId); 48 | 49 | if (coupon.AmountOff.HasValue && !removeDiscount) 50 | { 51 | return (long)coupon.AmountOff; 52 | } 53 | else if (coupon.PercentOff.HasValue && !removeDiscount) 54 | { 55 | return (long)Math.Round(amount * (coupon.PercentOff.Value / 100), MidpointRounding.AwayFromZero); 56 | } 57 | 58 | return 0; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /API/Services/ImageService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using API.RequestHelpers; 3 | using CloudinaryDotNet; 4 | using CloudinaryDotNet.Actions; 5 | using Microsoft.Extensions.Options; 6 | 7 | namespace API.Services; 8 | 9 | public class ImageService 10 | { 11 | private readonly Cloudinary _cloudinary; 12 | public ImageService(IOptions config) 13 | { 14 | var acc = new Account( 15 | config.Value.CloudName, 16 | config.Value.ApiKey, 17 | config.Value.ApiSecret 18 | ); 19 | 20 | _cloudinary = new Cloudinary(acc); 21 | } 22 | 23 | public async Task AddImageAsync(IFormFile file) 24 | { 25 | var uploadResult = new ImageUploadResult(); 26 | 27 | if (file.Length > 0) 28 | { 29 | using var stream = file.OpenReadStream(); 30 | var uploadParams = new ImageUploadParams 31 | { 32 | File = new FileDescription(file.FileName, stream), 33 | Folder = "rs-course" 34 | }; 35 | uploadResult = await _cloudinary.UploadAsync(uploadParams); 36 | } 37 | 38 | return uploadResult; 39 | } 40 | 41 | public async Task DeleteImageAsync(string publicId) 42 | { 43 | var deleteParams = new DeletionParams(publicId); 44 | 45 | var result = await _cloudinary.DestroyAsync(deleteParams); 46 | 47 | return result; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /API/Services/PaymentsService.cs: -------------------------------------------------------------------------------- 1 | using API.Entities; 2 | using Stripe; 3 | 4 | namespace API.Services; 5 | 6 | public class PaymentsService(IConfiguration config, DiscountService discountService) 7 | { 8 | public async Task CreateOrUpdatePaymentIntent(Basket basket, 9 | bool removeDiscount = false) 10 | { 11 | StripeConfiguration.ApiKey = config["StripeSettings:SecretKey"]; 12 | 13 | var service = new PaymentIntentService(); 14 | 15 | var intent = new PaymentIntent(); 16 | long subtotal = basket.Items.Sum(x => x.Quantity * x.Product.Price); 17 | long deliveryFee = subtotal > 10000 ? 0 : 500; 18 | long discount = 0; 19 | 20 | if (basket.Coupon != null) 21 | { 22 | discount = await discountService.CalculateDiscountFromAmount(basket.Coupon, subtotal, removeDiscount); 23 | } 24 | 25 | var totalAmount = subtotal - discount + deliveryFee; 26 | 27 | if (string.IsNullOrEmpty(basket.PaymentIntentId)) 28 | { 29 | var options = new PaymentIntentCreateOptions 30 | { 31 | Amount = totalAmount, 32 | Currency = "usd", 33 | PaymentMethodTypes = ["card"] 34 | }; 35 | intent = await service.CreateAsync(options); 36 | } 37 | else 38 | { 39 | var options = new PaymentIntentUpdateOptions 40 | { 41 | Amount = totalAmount 42 | }; 43 | await service.UpdateAsync(basket.PaymentIntentId, options); 44 | } 45 | 46 | return intent; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /API/WeatherForecast.cs: -------------------------------------------------------------------------------- 1 | namespace API; 2 | 3 | public class WeatherForecast 4 | { 5 | public DateOnly Date { get; set; } 6 | 7 | public int TemperatureC { get; set; } 8 | 9 | public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); 10 | 11 | public string? Summary { get; set; } 12 | } 13 | -------------------------------------------------------------------------------- /API/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Information" 6 | } 7 | }, 8 | "ConnectionStrings": { 9 | "DefaultConnection": "Server=localhost,1433;Database=shop;User Id=sa;Password=Password@1;TrustServerCertificate=True" 10 | }, 11 | "StripeSettings": { 12 | "PublishableKey": "pk_test_51QPJFxK9CgZZb0E8JK17GM40Z3IWzBomKDrog3knT8QasNNujX5Woesq5jSZXmSIKWE7R8Zg9oyhqc55ZHmpiGpG00dWUfDJVd", 13 | "SecretKey": "sk_test_51QPJFxK9CgZZb0E8XmmlNUOmIzEfaWlUNyCJLFwCy8d6i5NBlNOzeXZEcEbRJRV7yzIkLOmc6rof3xjyhaPvBc6A00aGxPIVKG", 14 | "WhSecret": "whsec_b913636a331248f27f552017e94a46a79335ab3dc6ead6cd90fd912301f29ff4" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /API/store.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/store.db -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-cyrillic-300-normal-DJfICpyc.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-cyrillic-300-normal-DJfICpyc.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-cyrillic-300-normal-Dg7J0kAT.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-cyrillic-300-normal-Dg7J0kAT.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-cyrillic-400-normal-BiRJyiea.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-cyrillic-400-normal-BiRJyiea.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-cyrillic-400-normal-JN0iKxGs.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-cyrillic-400-normal-JN0iKxGs.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-cyrillic-500-normal-YnJLGrUm.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-cyrillic-500-normal-YnJLGrUm.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-cyrillic-500-normal-_hamcpv8.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-cyrillic-500-normal-_hamcpv8.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-cyrillic-700-normal-BJaAVvFw.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-cyrillic-700-normal-BJaAVvFw.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-cyrillic-700-normal-jruQITdB.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-cyrillic-700-normal-jruQITdB.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-cyrillic-ext-300-normal-BLLmCegk.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-cyrillic-ext-300-normal-BLLmCegk.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-cyrillic-ext-300-normal-Chhwl1Jq.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-cyrillic-ext-300-normal-Chhwl1Jq.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-cyrillic-ext-400-normal-D76n7Daw.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-cyrillic-ext-400-normal-D76n7Daw.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-cyrillic-ext-400-normal-b0JluIOJ.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-cyrillic-ext-400-normal-b0JluIOJ.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-cyrillic-ext-500-normal-37WQE4S0.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-cyrillic-ext-500-normal-37WQE4S0.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-cyrillic-ext-500-normal-BJvL3D7h.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-cyrillic-ext-500-normal-BJvL3D7h.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-cyrillic-ext-700-normal-CyZgh00P.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-cyrillic-ext-700-normal-CyZgh00P.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-cyrillic-ext-700-normal-DXzexxfu.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-cyrillic-ext-700-normal-DXzexxfu.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-greek-300-normal-Bx8edVml.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-greek-300-normal-Bx8edVml.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-greek-300-normal-D3gN5oZ1.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-greek-300-normal-D3gN5oZ1.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-greek-400-normal-IIc_WWwF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-greek-400-normal-IIc_WWwF.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-greek-400-normal-LPh2sqOm.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-greek-400-normal-LPh2sqOm.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-greek-500-normal-Bg8BLohm.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-greek-500-normal-Bg8BLohm.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-greek-500-normal-CdRewbqV.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-greek-500-normal-CdRewbqV.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-greek-700-normal-1IZ-NEfb.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-greek-700-normal-1IZ-NEfb.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-greek-700-normal-Bs05n1ZH.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-greek-700-normal-Bs05n1ZH.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-latin-300-normal-BZ6gvbSO.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-latin-300-normal-BZ6gvbSO.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-latin-300-normal-BizgZZ3y.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-latin-300-normal-BizgZZ3y.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-latin-400-normal-BVyCgWwA.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-latin-400-normal-BVyCgWwA.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-latin-400-normal-DXyFPIdK.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-latin-400-normal-DXyFPIdK.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-latin-500-normal-C6iW8rdg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-latin-500-normal-C6iW8rdg.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-latin-500-normal-rpP1_v3s.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-latin-500-normal-rpP1_v3s.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-latin-700-normal-BWcFiwQV.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-latin-700-normal-BWcFiwQV.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-latin-700-normal-CbYYDfWS.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-latin-700-normal-CbYYDfWS.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-latin-ext-300-normal-BzRVPTS2.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-latin-ext-300-normal-BzRVPTS2.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-latin-ext-300-normal-Djx841zm.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-latin-ext-300-normal-Djx841zm.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-latin-ext-400-normal-BSFkPfbf.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-latin-ext-400-normal-BSFkPfbf.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-latin-ext-400-normal-DgXbz5gU.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-latin-ext-400-normal-DgXbz5gU.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-latin-ext-500-normal-DvHxAkTn.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-latin-ext-500-normal-DvHxAkTn.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-latin-ext-500-normal-OQJhyaXd.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-latin-ext-500-normal-OQJhyaXd.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-latin-ext-700-normal-Ba-CAIIA.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-latin-ext-700-normal-Ba-CAIIA.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-latin-ext-700-normal-DchBbzVz.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-latin-ext-700-normal-DchBbzVz.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-vietnamese-300-normal-CAomnZLO.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-vietnamese-300-normal-CAomnZLO.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-vietnamese-300-normal-PZa9KE_J.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-vietnamese-300-normal-PZa9KE_J.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-vietnamese-400-normal-D5pJwT9g.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-vietnamese-400-normal-D5pJwT9g.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-vietnamese-400-normal-DhTUfTw_.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-vietnamese-400-normal-DhTUfTw_.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-vietnamese-500-normal-LvuCHq7y.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-vietnamese-500-normal-LvuCHq7y.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-vietnamese-500-normal-p0V0BAAE.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-vietnamese-500-normal-p0V0BAAE.woff2 -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-vietnamese-700-normal-B4Nagvlm.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-vietnamese-700-normal-B4Nagvlm.woff -------------------------------------------------------------------------------- /API/wwwroot/assets/roboto-vietnamese-700-normal-CBbheh0s.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/assets/roboto-vietnamese-700-normal-CBbheh0s.woff2 -------------------------------------------------------------------------------- /API/wwwroot/images/hero1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/images/hero1.jpg -------------------------------------------------------------------------------- /API/wwwroot/images/hero2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/images/hero2.jpg -------------------------------------------------------------------------------- /API/wwwroot/images/hero3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/images/hero3.jpg -------------------------------------------------------------------------------- /API/wwwroot/images/hero4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/images/hero4.png -------------------------------------------------------------------------------- /API/wwwroot/images/products/boot-ang1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/images/products/boot-ang1.png -------------------------------------------------------------------------------- /API/wwwroot/images/products/boot-ang2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/images/products/boot-ang2.png -------------------------------------------------------------------------------- /API/wwwroot/images/products/boot-core1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/images/products/boot-core1.png -------------------------------------------------------------------------------- /API/wwwroot/images/products/boot-core2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/images/products/boot-core2.png -------------------------------------------------------------------------------- /API/wwwroot/images/products/boot-redis1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/images/products/boot-redis1.png -------------------------------------------------------------------------------- /API/wwwroot/images/products/glove-code1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/images/products/glove-code1.png -------------------------------------------------------------------------------- /API/wwwroot/images/products/glove-code2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/images/products/glove-code2.png -------------------------------------------------------------------------------- /API/wwwroot/images/products/glove-react1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/images/products/glove-react1.png -------------------------------------------------------------------------------- /API/wwwroot/images/products/glove-react2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/images/products/glove-react2.png -------------------------------------------------------------------------------- /API/wwwroot/images/products/hat-core1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/images/products/hat-core1.png -------------------------------------------------------------------------------- /API/wwwroot/images/products/hat-react1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/images/products/hat-react1.png -------------------------------------------------------------------------------- /API/wwwroot/images/products/hat-react2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/images/products/hat-react2.png -------------------------------------------------------------------------------- /API/wwwroot/images/products/sb-ang1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/images/products/sb-ang1.png -------------------------------------------------------------------------------- /API/wwwroot/images/products/sb-ang2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/images/products/sb-ang2.png -------------------------------------------------------------------------------- /API/wwwroot/images/products/sb-core1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/images/products/sb-core1.png -------------------------------------------------------------------------------- /API/wwwroot/images/products/sb-core2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/images/products/sb-core2.png -------------------------------------------------------------------------------- /API/wwwroot/images/products/sb-react1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/images/products/sb-react1.png -------------------------------------------------------------------------------- /API/wwwroot/images/products/sb-ts1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/API/wwwroot/images/products/sb-ts1.png -------------------------------------------------------------------------------- /API/wwwroot/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Re-store 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /API/wwwroot/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Restore Udemy Course repository 2 | 3 | This is the updated repository for the .Net 9.0, React 19 and Redux + RTK Query version of the course rewritten as at December 2024 4 | 5 | View a demo of this app [here](https://restore-course.azurewebsites.net/). 6 | 7 | You can see how this app was made by checking out the Udemy course for this here (with discount) 8 | 9 | [Udemy course](https://www.udemy.com/course/learn-to-build-an-e-commerce-store-with-dotnet-react-redux/?couponCode=GITHUBRESTORE) 10 | 11 | # Previous versions of the code 12 | 13 | If you are looking for the repository for the version of this app created on .Net 7.0, React 18 then this is available here: 14 | 15 | https://github.com/TryCatchLearn/Restore 16 | 17 | If you are looking for the repository for the version of this app created on .Net 6.0 and Angular v12 then this is available here: 18 | 19 | https://github.com/TryCatchLearn/Restore-v6 20 | -------------------------------------------------------------------------------- /Restore.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.31903.59 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "API", "API\API.csproj", "{BBBB7194-5487-4033-BC07-F3C91A8C0D2B}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Release|Any CPU = Release|Any CPU 12 | EndGlobalSection 13 | GlobalSection(SolutionProperties) = preSolution 14 | HideSolutionNode = FALSE 15 | EndGlobalSection 16 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 17 | {BBBB7194-5487-4033-BC07-F3C91A8C0D2B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 18 | {BBBB7194-5487-4033-BC07-F3C91A8C0D2B}.Debug|Any CPU.Build.0 = Debug|Any CPU 19 | {BBBB7194-5487-4033-BC07-F3C91A8C0D2B}.Release|Any CPU.ActiveCfg = Release|Any CPU 20 | {BBBB7194-5487-4033-BC07-F3C91A8C0D2B}.Release|Any CPU.Build.0 = Release|Any CPU 21 | EndGlobalSection 22 | EndGlobal 23 | -------------------------------------------------------------------------------- /client/.env.production: -------------------------------------------------------------------------------- 1 | VITE_API_URL=/api 2 | VITE_STRIPE_PK=pk_test_51QPJFxK9CgZZb0E8JK17GM40Z3IWzBomKDrog3knT8QasNNujX5Woesq5jSZXmSIKWE7R8Zg9oyhqc55ZHmpiGpG00dWUfDJVd -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | export default tseslint.config({ 18 | languageOptions: { 19 | // other options... 20 | parserOptions: { 21 | project: ['./tsconfig.node.json', './tsconfig.app.json'], 22 | tsconfigRootDir: import.meta.dirname, 23 | }, 24 | }, 25 | }) 26 | ``` 27 | 28 | - Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` 29 | - Optionally add `...tseslint.configs.stylisticTypeChecked` 30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: 31 | 32 | ```js 33 | // eslint.config.js 34 | import react from 'eslint-plugin-react' 35 | 36 | export default tseslint.config({ 37 | // Set the react version 38 | settings: { react: { version: '18.3' } }, 39 | plugins: { 40 | // Add the react plugin 41 | react, 42 | }, 43 | rules: { 44 | // other rules... 45 | // Enable its recommended rules 46 | ...react.configs.recommended.rules, 47 | ...react.configs['jsx-runtime'].rules, 48 | }, 49 | }) 50 | ``` 51 | -------------------------------------------------------------------------------- /client/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Re-store 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@emotion/react": "^11.13.3", 14 | "@emotion/styled": "^11.13.0", 15 | "@fontsource/roboto": "^5.1.0", 16 | "@hookform/resolvers": "^3.9.1", 17 | "@mui/icons-material": "^6.1.7", 18 | "@mui/lab": "^6.0.0-beta.17", 19 | "@mui/material": "^6.1.7", 20 | "@reduxjs/toolkit": "^2.3.0", 21 | "@stripe/react-stripe-js": "^3.0.0", 22 | "@stripe/stripe-js": "^5.2.0", 23 | "date-fns": "^4.1.0", 24 | "js-cookie": "^3.0.5", 25 | "react": "^19.0.0-rc-b01722d5-20241114", 26 | "react-dom": "^19.0.0-rc-b01722d5-20241114", 27 | "react-dropzone": "^14.3.5", 28 | "react-hook-form": "^7.53.2", 29 | "react-redux": "^9.1.2", 30 | "react-router-dom": "^6.28.0", 31 | "react-toastify": "^10.0.6", 32 | "zod": "^3.23.8" 33 | }, 34 | "devDependencies": { 35 | "@eslint/js": "^9.13.0", 36 | "@types/js-cookie": "^3.0.6", 37 | "@types/react": "^18.3.12", 38 | "@types/react-dom": "^18.3.1", 39 | "@vitejs/plugin-react-swc": "^3.5.0", 40 | "eslint": "^9.13.0", 41 | "eslint-plugin-react-hooks": "^5.0.0", 42 | "eslint-plugin-react-refresh": "^0.4.14", 43 | "globals": "^15.11.0", 44 | "typescript": "~5.6.2", 45 | "typescript-eslint": "^8.11.0", 46 | "vite": "^5.4.10", 47 | "vite-plugin-mkcert": "^1.17.6" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /client/public/images/hero1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/client/public/images/hero1.jpg -------------------------------------------------------------------------------- /client/public/images/hero2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/client/public/images/hero2.jpg -------------------------------------------------------------------------------- /client/public/images/hero3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/client/public/images/hero3.jpg -------------------------------------------------------------------------------- /client/public/images/hero4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/client/public/images/hero4.png -------------------------------------------------------------------------------- /client/public/images/products/boot-ang1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/client/public/images/products/boot-ang1.png -------------------------------------------------------------------------------- /client/public/images/products/boot-ang2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/client/public/images/products/boot-ang2.png -------------------------------------------------------------------------------- /client/public/images/products/boot-core1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/client/public/images/products/boot-core1.png -------------------------------------------------------------------------------- /client/public/images/products/boot-core2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/client/public/images/products/boot-core2.png -------------------------------------------------------------------------------- /client/public/images/products/boot-redis1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/client/public/images/products/boot-redis1.png -------------------------------------------------------------------------------- /client/public/images/products/glove-code1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/client/public/images/products/glove-code1.png -------------------------------------------------------------------------------- /client/public/images/products/glove-code2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/client/public/images/products/glove-code2.png -------------------------------------------------------------------------------- /client/public/images/products/glove-react1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/client/public/images/products/glove-react1.png -------------------------------------------------------------------------------- /client/public/images/products/glove-react2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/client/public/images/products/glove-react2.png -------------------------------------------------------------------------------- /client/public/images/products/hat-core1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/client/public/images/products/hat-core1.png -------------------------------------------------------------------------------- /client/public/images/products/hat-react1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/client/public/images/products/hat-react1.png -------------------------------------------------------------------------------- /client/public/images/products/hat-react2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/client/public/images/products/hat-react2.png -------------------------------------------------------------------------------- /client/public/images/products/sb-ang1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/client/public/images/products/sb-ang1.png -------------------------------------------------------------------------------- /client/public/images/products/sb-ang2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/client/public/images/products/sb-ang2.png -------------------------------------------------------------------------------- /client/public/images/products/sb-core1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/client/public/images/products/sb-core1.png -------------------------------------------------------------------------------- /client/public/images/products/sb-core2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/client/public/images/products/sb-core2.png -------------------------------------------------------------------------------- /client/public/images/products/sb-react1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/client/public/images/products/sb-react1.png -------------------------------------------------------------------------------- /client/public/images/products/sb-ts1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/client/public/images/products/sb-ts1.png -------------------------------------------------------------------------------- /client/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/app/api/baseApi.ts: -------------------------------------------------------------------------------- 1 | import { BaseQueryApi, FetchArgs, fetchBaseQuery } from "@reduxjs/toolkit/query"; 2 | import { startLoading, stopLoading } from "../layout/uiSlice"; 3 | import { toast } from "react-toastify"; 4 | import { router } from "../routes/Routes"; 5 | 6 | const customBaseQuery = fetchBaseQuery({ 7 | baseUrl: import.meta.env.VITE_API_URL, 8 | credentials: 'include' 9 | }); 10 | 11 | type ErrorResponse = | string | { title: string } | { errors: string[] }; 12 | 13 | const sleep = () => new Promise(resolve => setTimeout(resolve, 1000)); 14 | 15 | export const baseQueryWithErrorHandling = async (args: string | FetchArgs, api: BaseQueryApi, 16 | extraOptions: object) => { 17 | api.dispatch(startLoading()); 18 | if (import.meta.env.DEV) await sleep(); 19 | const result = await customBaseQuery(args, api, extraOptions); 20 | api.dispatch(stopLoading()); 21 | if (result.error) { 22 | console.log(result.error); 23 | 24 | const originalStatus = result.error.status === 'PARSING_ERROR' && result.error.originalStatus 25 | ? result.error.originalStatus 26 | : result.error.status 27 | 28 | const responseData = result.error.data as ErrorResponse; 29 | 30 | switch (originalStatus) { 31 | case 400: 32 | if (typeof responseData === 'string') toast.error(responseData); 33 | else if ('errors' in responseData) { 34 | throw Object.values(responseData.errors).flat().join(', ') 35 | } 36 | else toast.error(responseData.title); 37 | break; 38 | case 401: 39 | if (typeof responseData === 'object' && 'title' in responseData) 40 | toast.error(responseData.title); 41 | break; 42 | case 403: 43 | if (typeof responseData === 'object') 44 | toast.error('403 Forbidden'); 45 | break; 46 | case 404: 47 | if (typeof responseData === 'object' && 'title' in responseData) 48 | router.navigate('/not-found') 49 | break; 50 | case 500: 51 | if (typeof responseData === 'object') 52 | router.navigate('/server-error', { state: { error: responseData } }) 53 | break; 54 | default: 55 | break; 56 | } 57 | } 58 | 59 | return result; 60 | } -------------------------------------------------------------------------------- /client/src/app/errors/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import { SearchOff } from "@mui/icons-material"; 2 | import { Button, Paper, Typography } from "@mui/material"; 3 | import { Link } from "react-router-dom"; 4 | 5 | export default function NotFound() { 6 | return ( 7 | 17 | 18 | 19 | Oops - we could not find what you were looking for 20 | 21 | 24 | 25 | ) 26 | } -------------------------------------------------------------------------------- /client/src/app/errors/ServerError.tsx: -------------------------------------------------------------------------------- 1 | import { Divider, Paper, Typography } from "@mui/material"; 2 | import { useLocation } from "react-router-dom" 3 | 4 | export default function ServerError() { 5 | const {state} = useLocation(); 6 | 7 | return ( 8 | 9 | {state.error ? ( 10 | <> 11 | 12 | {state.error.title} 13 | 14 | 15 | {state.error.detail} 16 | 17 | ) : ( 18 | Server error 19 | )} 20 | 21 | ) 22 | } -------------------------------------------------------------------------------- /client/src/app/layout/App.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Container, createTheme, CssBaseline, ThemeProvider } from "@mui/material"; 2 | import NavBar from "./NavBar"; 3 | import { Outlet, ScrollRestoration } from "react-router-dom"; 4 | import { useAppSelector } from "../store/store"; 5 | 6 | 7 | function App() { 8 | const {darkMode} = useAppSelector(state => state.ui); 9 | const palleteType = darkMode ? 'dark' : 'light' 10 | const theme = createTheme({ 11 | palette: { 12 | mode: palleteType, 13 | background: { 14 | default: (palleteType === 'light') ? '#eaeaea' : '#121212' 15 | } 16 | } 17 | }); 18 | 19 | return ( 20 | 21 | 22 | 23 | 24 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | ) 41 | } 42 | 43 | export default App 44 | -------------------------------------------------------------------------------- /client/src/app/layout/NavBar.tsx: -------------------------------------------------------------------------------- 1 | import { AppBar, Badge, Box, IconButton, LinearProgress, List, ListItem, Toolbar, Typography } from "@mui/material"; 2 | import { DarkMode, LightMode, ShoppingCart } from '@mui/icons-material'; 3 | import { Link, NavLink } from "react-router-dom"; 4 | import { useAppDispatch, useAppSelector } from "../store/store"; 5 | import { setDarkMode } from "./uiSlice"; 6 | import { useFetchBasketQuery } from "../../features/basket/basketApi"; 7 | import UserMenu from "./UserMenu"; 8 | import { useUserInfoQuery } from "../../features/account/accountApi"; 9 | 10 | const midLinks = [ 11 | { title: 'catalog', path: '/catalog' }, 12 | { title: 'about', path: '/about' }, 13 | { title: 'contact', path: '/contact' }, 14 | ] 15 | 16 | const rightLinks = [ 17 | { title: 'login', path: '/login' }, 18 | { title: 'register', path: '/register' } 19 | ] 20 | 21 | const navStyles = { 22 | color: 'inherit', 23 | typography: 'h6', 24 | textDecoration: 'none', 25 | '&:hover': { 26 | color: 'grey.500' 27 | }, 28 | '&.active': { 29 | color: '#baecf9' 30 | } 31 | } 32 | 33 | export default function NavBar() { 34 | const {data: user} = useUserInfoQuery(); 35 | const { isLoading, darkMode } = useAppSelector(state => state.ui); 36 | const dispatch = useAppDispatch(); 37 | const { data: basket } = useFetchBasketQuery(); 38 | 39 | const itemCount = basket?.items.reduce((sum, item) => sum + item.quantity, 0) || 0; 40 | 41 | return ( 42 | 43 | 44 | 45 | RE-STORE 46 | dispatch(setDarkMode())}> 47 | {darkMode ? : } 48 | 49 | 50 | 51 | 52 | {midLinks.map(({ title, path }) => ( 53 | 59 | {title.toUpperCase()} 60 | 61 | ))} 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | {user ? ( 72 | 73 | ) : ( 74 | 75 | {rightLinks.map(({ title, path }) => ( 76 | 82 | {title.toUpperCase()} 83 | 84 | ))} 85 | 86 | )} 87 | 88 | 89 | 90 | 91 | 92 | {isLoading && ( 93 | 94 | 95 | 96 | )} 97 | 98 | ) 99 | } -------------------------------------------------------------------------------- /client/src/app/layout/UserMenu.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Menu, Fade, MenuItem, ListItemIcon, ListItemText, Divider } from "@mui/material"; 2 | import { useState } from "react"; 3 | import { User } from "../models/user"; 4 | import { History, Inventory, Logout, Person } from "@mui/icons-material"; 5 | import { useLogoutMutation } from "../../features/account/accountApi"; 6 | import { Link } from "react-router-dom"; 7 | 8 | type Props = { 9 | user: User 10 | } 11 | 12 | export default function UserMenu({ user }: Props) { 13 | const [logout] = useLogoutMutation(); 14 | const [anchorEl, setAnchorEl] = useState(null); 15 | const open = Boolean(anchorEl); 16 | const handleClick = (event: React.MouseEvent) => { 17 | setAnchorEl(event.currentTarget); 18 | }; 19 | const handleClose = () => { 20 | setAnchorEl(null); 21 | }; 22 | 23 | return ( 24 |
25 | 33 | 43 | 44 | 45 | 46 | 47 | My profile 48 | 49 | 50 | 51 | 52 | 53 | My orders 54 | 55 | {user.roles.includes('Admin') && 56 | 57 | 58 | 59 | 60 | Inventory 61 | } 62 | 63 | 64 | 65 | 66 | 67 | Logout 68 | 69 | 70 |
71 | ); 72 | } -------------------------------------------------------------------------------- /client/src/app/layout/styles.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v2/5a9f47c8b6c1c03925e5a0855bf5012864d874be/client/src/app/layout/styles.css -------------------------------------------------------------------------------- /client/src/app/layout/uiSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | 3 | const getInitialDarkMode = () => { 4 | const storedDarkMode = localStorage.getItem('darkMode'); 5 | return storedDarkMode ? JSON.parse(storedDarkMode) : true 6 | } 7 | 8 | export const uiSlice = createSlice({ 9 | name: 'ui', 10 | initialState: { 11 | isLoading: false, 12 | darkMode: getInitialDarkMode() 13 | }, 14 | reducers: { 15 | startLoading: (state) => { 16 | state.isLoading = true; 17 | }, 18 | stopLoading: (state) => { 19 | state.isLoading = false; 20 | }, 21 | setDarkMode: (state) => { 22 | localStorage.setItem('darkMode', JSON.stringify(!state.darkMode)); 23 | state.darkMode = !state.darkMode 24 | } 25 | } 26 | }); 27 | 28 | export const {startLoading, stopLoading, setDarkMode} = uiSlice.actions; -------------------------------------------------------------------------------- /client/src/app/models/basket.ts: -------------------------------------------------------------------------------- 1 | export type Basket = { 2 | basketId: string 3 | items: Item[] 4 | clientSecret?: string 5 | paymentIntentId?: string 6 | coupon: Coupon | null 7 | } 8 | 9 | export type Item = { 10 | productId: number 11 | name: string 12 | price: number 13 | pictureUrl: string 14 | brand: string 15 | type: string 16 | quantity: number 17 | } 18 | 19 | export type Coupon = { 20 | name: string; 21 | amountOff?: number; 22 | percentOff?: number; 23 | promotionCode: string; 24 | couponId: string; 25 | } -------------------------------------------------------------------------------- /client/src/app/models/order.ts: -------------------------------------------------------------------------------- 1 | export interface Order { 2 | id: number 3 | buyerEmail: string 4 | shippingAddress: ShippingAddress 5 | orderDate: string 6 | orderItems: OrderItem[] 7 | subtotal: number 8 | deliveryFee: number 9 | discount: number 10 | total: number 11 | orderStatus: string 12 | paymentSummary: PaymentSummary 13 | } 14 | 15 | export interface ShippingAddress { 16 | name: string 17 | line1: string 18 | line2?: string | null 19 | city: string 20 | state: string 21 | postal_code: string 22 | country: string 23 | } 24 | 25 | export interface OrderItem { 26 | productId: number 27 | name: string 28 | pictureUrl: string 29 | price: number 30 | quantity: number 31 | } 32 | 33 | export interface PaymentSummary { 34 | last4: number | string 35 | brand: string 36 | exp_month: number 37 | exp_year: number 38 | } 39 | 40 | export interface CreateOrder { 41 | shippingAddress: ShippingAddress 42 | paymentSummary: PaymentSummary 43 | } -------------------------------------------------------------------------------- /client/src/app/models/pagination.ts: -------------------------------------------------------------------------------- 1 | export type Pagination = { 2 | currentPage: number; 3 | totalPages: number; 4 | pageSize: number; 5 | totalCount: number; 6 | } -------------------------------------------------------------------------------- /client/src/app/models/product.ts: -------------------------------------------------------------------------------- 1 | export type Product = { 2 | id: number 3 | name: string 4 | description: string 5 | price: number 6 | pictureUrl: string 7 | type: string 8 | brand: string 9 | quantityInStock: number 10 | } -------------------------------------------------------------------------------- /client/src/app/models/productParams.ts: -------------------------------------------------------------------------------- 1 | export type ProductParams = { 2 | orderBy: string; 3 | searchTerm?: string; 4 | types: string[]; 5 | brands: string[]; 6 | pageNumber: number; 7 | pageSize: number; 8 | } -------------------------------------------------------------------------------- /client/src/app/models/user.ts: -------------------------------------------------------------------------------- 1 | export type User = { 2 | email: string; 3 | roles: string[]; 4 | } 5 | 6 | export type Address = { 7 | name: string 8 | line1: string 9 | line2?: string | null 10 | city: string 11 | state: string 12 | postal_code: string 13 | country: string 14 | } -------------------------------------------------------------------------------- /client/src/app/routes/RequireAuth.tsx: -------------------------------------------------------------------------------- 1 | import { Navigate, Outlet, useLocation } from "react-router-dom"; 2 | import { useUserInfoQuery } from "../../features/account/accountApi" 3 | 4 | export default function RequireAuth() { 5 | const {data: user, isLoading} = useUserInfoQuery(); 6 | const location = useLocation(); 7 | 8 | if (isLoading) return
Loading...
9 | 10 | if (!user) { 11 | return 12 | } 13 | 14 | const adminRoutes = [ 15 | '/inventory', 16 | '/admin-dashboard' 17 | ] 18 | 19 | if (adminRoutes.includes(location.pathname) && !user.roles.includes('Admin')) { 20 | return 21 | } 22 | 23 | return ( 24 | 25 | ) 26 | } -------------------------------------------------------------------------------- /client/src/app/routes/Routes.tsx: -------------------------------------------------------------------------------- 1 | import { createBrowserRouter, Navigate } from "react-router-dom"; 2 | import App from "../layout/App"; 3 | import HomePage from "../../features/home/HomePage"; 4 | import Catalog from "../../features/catalog/Catalog"; 5 | import ProductDetails from "../../features/catalog/ProductDetails"; 6 | import AboutPage from "../../features/about/AboutPage"; 7 | import ContactPage from "../../features/contact/ContactPage"; 8 | import ServerError from "../errors/ServerError"; 9 | import NotFound from "../errors/NotFound"; 10 | import BasketPage from "../../features/basket/BasketPage"; 11 | import CheckoutPage from "../../features/checkout/CheckoutPage"; 12 | import LoginForm from "../../features/account/LoginForm"; 13 | import RegisterForm from "../../features/account/RegisterForm"; 14 | import RequireAuth from "./RequireAuth"; 15 | import CheckoutSuccess from "../../features/checkout/CheckoutSuccess"; 16 | import OrdersPage from "../../features/orders/OrdersPage"; 17 | import OrderDetailedPage from "../../features/orders/OrderDetailedPage"; 18 | import InventoryPage from "../../features/admin/InventoryPage"; 19 | 20 | export const router = createBrowserRouter([ 21 | { 22 | path: '/', 23 | element: , 24 | children: [ 25 | {element: , children: [ 26 | {path: 'checkout', element: }, 27 | {path: 'checkout/success', element: }, 28 | {path: 'orders', element: }, 29 | {path: 'orders/:id', element: }, 30 | {path: 'inventory', element: }, 31 | ]}, 32 | {path: '', element: }, 33 | {path: 'catalog', element: }, 34 | {path: 'catalog/:id', element: }, 35 | {path: 'about', element: }, 36 | {path: 'contact', element: }, 37 | {path: 'basket', element: }, 38 | {path: 'server-error', element: }, 39 | {path: 'login', element: }, 40 | {path: 'register', element: }, 41 | {path: 'not-found', element: }, 42 | {path: '*', element: } 43 | ] 44 | } 45 | ], { 46 | future: { 47 | v7_relativeSplatPath: true, 48 | v7_fetcherPersist: true, 49 | v7_normalizeFormMethod: true, 50 | v7_partialHydration: true, 51 | v7_skipActionErrorRevalidation: true 52 | } 53 | }) -------------------------------------------------------------------------------- /client/src/app/shared/components/AppDropzone.tsx: -------------------------------------------------------------------------------- 1 | import { UploadFile } from "@mui/icons-material"; 2 | import { FormControl, FormHelperText, Typography } from "@mui/material"; 3 | import { useCallback } from "react"; 4 | import { useDropzone } from "react-dropzone"; 5 | import { FieldValues, useController, UseControllerProps } from "react-hook-form" 6 | 7 | type Props = { 8 | name: keyof T 9 | } & UseControllerProps 10 | 11 | export default function AppDropzone(props: Props) { 12 | const { fieldState, field } = useController({ ...props }); 13 | 14 | const onDrop = useCallback((acceptedFiles: File[]) => { 15 | if (acceptedFiles.length > 0) { 16 | const fileWithPreview = Object.assign(acceptedFiles[0], { 17 | preview: URL.createObjectURL(acceptedFiles[0]) 18 | }); 19 | 20 | field.onChange(fileWithPreview); 21 | } 22 | }, [field]); 23 | 24 | const { getRootProps, getInputProps, isDragActive } = useDropzone({onDrop}); 25 | 26 | const dzStyles = { 27 | display: 'flex', 28 | border: 'dashed 2px #767676', 29 | borderColor: '#767676', 30 | borderRadius: '5px', 31 | paddingTop: '30px', 32 | alignItems: 'center', 33 | height: 200, 34 | width: 500 35 | } 36 | 37 | const dzActive = { 38 | borderColor: 'green' 39 | } 40 | 41 | return ( 42 |
43 | 47 | 48 | 49 | Drop image here 50 | {fieldState.error?.message} 51 | 52 |
53 | ) 54 | } -------------------------------------------------------------------------------- /client/src/app/shared/components/AppPagination.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Pagination, Typography } from "@mui/material"; 2 | import { Pagination as PaginationType } from "../../models/pagination"; 3 | 4 | type Props = { 5 | metadata: PaginationType 6 | onPageChange: (page: number) => void 7 | } 8 | 9 | export default function AppPagination({ metadata, onPageChange }: Props) { 10 | const {currentPage, totalPages, pageSize, totalCount} = metadata; 11 | 12 | const startItem = (currentPage - 1) * pageSize + 1; 13 | const endItem = Math.min(currentPage * pageSize, totalCount) 14 | 15 | return ( 16 | 17 | 18 | Displaying {startItem}-{endItem} of {totalCount} items 19 | 20 | onPageChange(page)} 26 | /> 27 | 28 | ) 29 | } -------------------------------------------------------------------------------- /client/src/app/shared/components/AppSelectInput.tsx: -------------------------------------------------------------------------------- 1 | import { FormControl, FormHelperText, InputLabel, MenuItem, Select } from "@mui/material"; 2 | import { SelectInputProps } from "@mui/material/Select/SelectInput"; 3 | import { FieldValues, useController, UseControllerProps } from "react-hook-form" 4 | 5 | type Props = { 6 | label: string 7 | name: keyof T 8 | items: string[] 9 | } & UseControllerProps & Partial 10 | 11 | export default function AppSelectInput(props: Props) { 12 | const {fieldState, field} = useController({...props}); 13 | 14 | return ( 15 | 16 | {props.label} 17 | 26 | {fieldState.error?.message} 27 | 28 | ) 29 | } -------------------------------------------------------------------------------- /client/src/app/shared/components/AppTextInput.tsx: -------------------------------------------------------------------------------- 1 | import { TextField, TextFieldProps } from "@mui/material"; 2 | import { FieldValues, useController, UseControllerProps } from "react-hook-form" 3 | 4 | type Props = { 5 | label: string 6 | name: keyof T 7 | } & UseControllerProps & TextFieldProps 8 | 9 | export default function AppTextInput(props: Props) { 10 | const {fieldState, field} = useController({...props}); 11 | 12 | return ( 13 | 25 | ) 26 | } -------------------------------------------------------------------------------- /client/src/app/shared/components/CheckboxButtons.tsx: -------------------------------------------------------------------------------- 1 | import { FormGroup, FormControlLabel, Checkbox } from "@mui/material"; 2 | import { useEffect, useState } from "react"; 3 | 4 | type Props = { 5 | items: string[]; 6 | checked: string[]; 7 | onChange: (items: string[]) => void; 8 | } 9 | 10 | export default function CheckboxButtons({items, checked, onChange}: Props) { 11 | const [checkedItems, setCheckedItems] = useState(checked); 12 | 13 | useEffect(() => { 14 | setCheckedItems(checked); 15 | }, [checked]); 16 | 17 | const handleToggle = (value: string) => { 18 | const updatedChecked = checkedItems?.includes(value) 19 | ? checkedItems.filter(item => item !== value) 20 | : [...checkedItems, value]; 21 | 22 | setCheckedItems(updatedChecked); 23 | onChange(updatedChecked); 24 | } 25 | 26 | return ( 27 | 28 | {items.map(item => ( 29 | handleToggle(item)} 34 | color='secondary' 35 | sx={{ py: 0.7, fontSize: 40 }} 36 | />} 37 | label={item} 38 | /> 39 | ))} 40 | 41 | ) 42 | } -------------------------------------------------------------------------------- /client/src/app/shared/components/OrderSummary.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Typography, Divider, Button, TextField, Paper } from "@mui/material"; 2 | import { currencyFormat } from "../../../lib/util"; 3 | import { Link, useLocation } from "react-router-dom"; 4 | import { useBasket } from "../../../lib/hooks/useBasket"; 5 | import { FieldValues, useForm } from "react-hook-form"; 6 | import { LoadingButton } from "@mui/lab"; 7 | import { useAddCouponMutation, useRemoveCouponMutation } from "../../../features/basket/basketApi"; 8 | import { Delete } from "@mui/icons-material"; 9 | 10 | export default function OrderSummary() { 11 | const {subtotal, deliveryFee, discount, basket, total} = useBasket(); 12 | const location = useLocation(); 13 | const {register, handleSubmit, formState: {isSubmitting}} = useForm(); 14 | const [addCoupon] = useAddCouponMutation(); 15 | const [removeCoupon, {isLoading}] = useRemoveCouponMutation(); 16 | 17 | const onSubmit = async (data: FieldValues) => { 18 | await addCoupon(data.code); 19 | } 20 | 21 | return ( 22 | 23 | 24 | 25 | 26 | Order summary 27 | 28 | 29 | Orders over $100 qualify for free delivery! 30 | 31 | 32 | 33 | Subtotal 34 | 35 | {currencyFormat(subtotal)} 36 | 37 | 38 | 39 | Discount 40 | 41 | -{currencyFormat(discount)} 42 | 43 | 44 | 45 | Delivery fee 46 | 47 | {currencyFormat(deliveryFee)} 48 | 49 | 50 | 51 | 52 | Total 53 | 54 | {currencyFormat(total)} 55 | 56 | 57 | 58 | 59 | 60 | {!location.pathname.includes('checkout') && 61 | } 71 | 78 | 79 | 80 | 81 | {/* Coupon Code Section */} 82 | {location.pathname.includes('checkout') && 83 | 84 | 85 |
86 | 87 | Do you have a voucher code? 88 | 89 | 90 | {basket?.coupon && 91 | 92 | {basket.coupon.name} applied 93 | removeCoupon()}> 94 | 95 | 96 | } 97 | 98 | 99 | 107 | 108 | 116 | Apply code 117 | 118 | 119 |
} 120 |
121 | ) 122 | } -------------------------------------------------------------------------------- /client/src/app/shared/components/RadioButtonGroup.tsx: -------------------------------------------------------------------------------- 1 | import { FormControl, FormControlLabel, Radio, RadioGroup } from "@mui/material"; 2 | import { ChangeEvent } from "react"; 3 | 4 | type Props = { 5 | options: { value: string, label: string }[] 6 | onChange: (event: ChangeEvent) => void 7 | selectedValue: string 8 | } 9 | 10 | export default function RadioButtonGroup({ options, onChange, selectedValue }: Props) { 11 | return ( 12 | 13 | 18 | {options.map(({ value, label }) => ( 19 | } 22 | label={label} 23 | value={value} 24 | /> 25 | ))} 26 | 27 | 28 | 29 | ) 30 | } -------------------------------------------------------------------------------- /client/src/app/store/store.ts: -------------------------------------------------------------------------------- 1 | import { configureStore, legacy_createStore } from "@reduxjs/toolkit"; 2 | import counterReducer, { counterSlice } from "../../features/contact/counterReducer"; 3 | import { useDispatch, useSelector } from "react-redux"; 4 | import { catalogApi } from "../../features/catalog/catalogApi"; 5 | import { uiSlice } from "../layout/uiSlice"; 6 | import { errorApi } from "../../features/about/errorApi"; 7 | import { basketApi } from "../../features/basket/basketApi"; 8 | import { catalogSlice } from "../../features/catalog/catalogSlice"; 9 | import { accountApi } from "../../features/account/accountApi"; 10 | import { checkoutApi } from "../../features/checkout/checkoutApi"; 11 | import { orderApi } from "../../features/orders/orderApi"; 12 | import { adminApi } from "../../features/admin/adminApi"; 13 | 14 | export function configureTheStore() { 15 | return legacy_createStore(counterReducer) 16 | } 17 | 18 | export const store = configureStore({ 19 | reducer: { 20 | [catalogApi.reducerPath]: catalogApi.reducer, 21 | [errorApi.reducerPath]: errorApi.reducer, 22 | [basketApi.reducerPath]: basketApi.reducer, 23 | [accountApi.reducerPath]: accountApi.reducer, 24 | [checkoutApi.reducerPath]: checkoutApi.reducer, 25 | [orderApi.reducerPath]: orderApi.reducer, 26 | [adminApi.reducerPath]: adminApi.reducer, 27 | counter: counterSlice.reducer, 28 | ui: uiSlice.reducer, 29 | catalog: catalogSlice.reducer 30 | }, 31 | middleware: (getDefaultMiddleware) => 32 | getDefaultMiddleware().concat( 33 | catalogApi.middleware, 34 | errorApi.middleware, 35 | basketApi.middleware, 36 | accountApi.middleware, 37 | checkoutApi.middleware, 38 | orderApi.middleware, 39 | adminApi.middleware 40 | ) 41 | }); 42 | 43 | export type RootState = ReturnType 44 | export type AppDispatch = typeof store.dispatch 45 | 46 | export const useAppDispatch = useDispatch.withTypes() 47 | export const useAppSelector = useSelector.withTypes() -------------------------------------------------------------------------------- /client/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/features/about/AboutPage.tsx: -------------------------------------------------------------------------------- 1 | import { Alert, AlertTitle, Button, ButtonGroup, Container, List, ListItem, Typography } from "@mui/material"; 2 | import { useLazyGet400ErrorQuery, useLazyGet401ErrorQuery, useLazyGet404ErrorQuery, useLazyGet500ErrorQuery, useLazyGetValidationErrorQuery } from "./errorApi"; 3 | import { useState } from "react"; 4 | 5 | export default function AboutPage() { 6 | const [validationErrors, setValidationErrors] = useState([]); 7 | 8 | const [trigger400Error] = useLazyGet400ErrorQuery(); 9 | const [trigger401Error] = useLazyGet401ErrorQuery(); 10 | const [trigger404Error] = useLazyGet404ErrorQuery(); 11 | const [trigger500Error] = useLazyGet500ErrorQuery(); 12 | const [triggerValidationError] = useLazyGetValidationErrorQuery(); 13 | 14 | const getValidatonError = async () => { 15 | try { 16 | await triggerValidationError().unwrap(); 17 | } catch (error: unknown) { 18 | if (error && typeof error === 'object' && 'message' in error 19 | && typeof (error as {message: unknown}).message === 'string') { 20 | const errorArray = (error as {message: string}).message.split(', '); 21 | setValidationErrors(errorArray); 22 | } 23 | 24 | } 25 | } 26 | 27 | return ( 28 | 29 | Errors for testing 30 | 31 | 35 | 39 | 43 | 47 | 50 | 51 | {validationErrors.length > 0 && ( 52 | 53 | Validation errors 54 | 55 | {validationErrors.map(err => ( 56 | {err} 57 | ))} 58 | 59 | 60 | )} 61 | 62 | ) 63 | } -------------------------------------------------------------------------------- /client/src/features/about/errorApi.ts: -------------------------------------------------------------------------------- 1 | import { createApi } from "@reduxjs/toolkit/query/react"; 2 | import { baseQueryWithErrorHandling } from "../../app/api/baseApi"; 3 | 4 | export const errorApi = createApi({ 5 | reducerPath: 'errorApi', 6 | baseQuery: baseQueryWithErrorHandling, 7 | endpoints: (builder) => ({ 8 | get400Error: builder.query({ 9 | query: () => ({url: 'buggy/bad-request'}) 10 | }), 11 | get401Error: builder.query({ 12 | query: () => ({url: 'buggy/unauthorized'}) 13 | }), 14 | get404Error: builder.query({ 15 | query: () => ({url: 'buggy/not-found'}) 16 | }), 17 | get500Error: builder.query({ 18 | query: () => ({url: 'buggy/server-error'}) 19 | }), 20 | getValidationError: builder.query({ 21 | query: () => ({url: 'buggy/validation-error'}) 22 | }), 23 | }) 24 | }); 25 | 26 | export const { 27 | useLazyGet400ErrorQuery, 28 | useLazyGet401ErrorQuery, 29 | useLazyGet500ErrorQuery, 30 | useLazyGet404ErrorQuery, 31 | useLazyGetValidationErrorQuery 32 | } = errorApi; -------------------------------------------------------------------------------- /client/src/features/account/LoginForm.tsx: -------------------------------------------------------------------------------- 1 | import { LockOutlined } from "@mui/icons-material"; 2 | import { Box, Button, Container, Paper, TextField, Typography } from "@mui/material"; 3 | import { useForm } from "react-hook-form"; 4 | import { Link, useLocation, useNavigate } from "react-router-dom"; 5 | import { loginSchema, LoginSchema } from "../../lib/schemas/loginSchema"; 6 | import { zodResolver } from "@hookform/resolvers/zod"; 7 | import { useLazyUserInfoQuery, useLoginMutation } from "./accountApi"; 8 | 9 | export default function LoginForm() { 10 | const [login, {isLoading}] = useLoginMutation(); 11 | const [fetchUserInfo] = useLazyUserInfoQuery(); 12 | const location = useLocation(); 13 | const {register, handleSubmit, formState: {errors}} = useForm({ 14 | mode: 'onTouched', 15 | resolver: zodResolver(loginSchema) 16 | }); 17 | const navigate = useNavigate(); 18 | 19 | const onSubmit = async (data: LoginSchema) => { 20 | await login(data); 21 | await fetchUserInfo(); 22 | navigate(location.state?.from || '/catalog'); 23 | } 24 | 25 | return ( 26 | 27 | 28 | 29 | 30 | Sign in 31 | 32 | 41 | 49 | 57 | 60 | 61 | Don't have an account? 62 | 63 | Sign up 64 | 65 | 66 | 67 | 68 | 69 | ) 70 | } -------------------------------------------------------------------------------- /client/src/features/account/RegisterForm.tsx: -------------------------------------------------------------------------------- 1 | import { useForm } from "react-hook-form"; 2 | import { useRegisterMutation } from "./accountApi" 3 | import { registerSchema, RegisterSchema } from "../../lib/schemas/registerSchema"; 4 | import { zodResolver } from "@hookform/resolvers/zod"; 5 | import { LockOutlined } from "@mui/icons-material"; 6 | import { Container, Paper, Box, Typography, TextField, Button } from "@mui/material"; 7 | import { Link } from "react-router-dom"; 8 | 9 | export default function RegisterForm() { 10 | const [registerUser] = useRegisterMutation(); 11 | const {register, handleSubmit, setError, formState: {errors, isValid, isLoading}} = useForm({ 12 | mode: 'onTouched', 13 | resolver: zodResolver(registerSchema) 14 | }) 15 | 16 | const onSubmit = async (data: RegisterSchema) => { 17 | try { 18 | await registerUser(data).unwrap(); 19 | } catch (error) { 20 | const apiError = error as {message: string}; 21 | if (apiError.message && typeof apiError.message === 'string') { 22 | const errorArray = apiError.message.split(','); 23 | 24 | errorArray.forEach(e => { 25 | if (e.includes('Password')) { 26 | setError('password', {message: e}) 27 | } else if (e.includes('Email')) { 28 | setError('email', {message: e}) 29 | } 30 | }) 31 | } 32 | } 33 | 34 | } 35 | 36 | return ( 37 | 38 | 39 | 40 | 41 | Register 42 | 43 | 52 | 60 | 68 | 71 | 72 | Already have an account? 73 | 74 | Sign in here 75 | 76 | 77 | 78 | 79 | 80 | ) 81 | } -------------------------------------------------------------------------------- /client/src/features/account/accountApi.ts: -------------------------------------------------------------------------------- 1 | import { createApi } from "@reduxjs/toolkit/query/react"; 2 | import { baseQueryWithErrorHandling } from "../../app/api/baseApi"; 3 | import { Address, User } from "../../app/models/user"; 4 | import { LoginSchema } from "../../lib/schemas/loginSchema"; 5 | import { router } from "../../app/routes/Routes"; 6 | import { toast } from "react-toastify"; 7 | 8 | export const accountApi = createApi({ 9 | reducerPath: 'accountApi', 10 | baseQuery: baseQueryWithErrorHandling, 11 | tagTypes: ['UserInfo'], 12 | endpoints: (builder) => ({ 13 | login: builder.mutation({ 14 | query: (creds) => { 15 | return { 16 | url: 'login?useCookies=true', 17 | method: 'POST', 18 | body: creds 19 | } 20 | }, 21 | async onQueryStarted(_, {dispatch, queryFulfilled}) { 22 | try { 23 | await queryFulfilled; 24 | dispatch(accountApi.util.invalidateTags(['UserInfo'])) 25 | } catch (error) { 26 | console.log(error); 27 | } 28 | } 29 | }), 30 | register: builder.mutation({ 31 | query: (creds) => { 32 | return { 33 | url: 'account/register', 34 | method: 'POST', 35 | body: creds 36 | } 37 | }, 38 | async onQueryStarted(_, {queryFulfilled}) { 39 | try { 40 | await queryFulfilled; 41 | toast.success('Registration successful - you can now sign in!'); 42 | router.navigate('/login'); 43 | } catch (error) { 44 | console.log(error); 45 | throw error; 46 | } 47 | } 48 | }), 49 | userInfo: builder.query({ 50 | query: () => 'account/user-info', 51 | providesTags: ['UserInfo'] 52 | }), 53 | logout: builder.mutation({ 54 | query: () => ({ 55 | url: 'account/logout', 56 | method: 'POST' 57 | }), 58 | async onQueryStarted(_, {dispatch, queryFulfilled}) { 59 | await queryFulfilled; 60 | dispatch(accountApi.util.invalidateTags(['UserInfo'])); 61 | router.navigate('/'); 62 | } 63 | }), 64 | fetchAddress: builder.query({ 65 | query: () => ({ 66 | url: 'account/address' 67 | }) 68 | }), 69 | updateUserAddress: builder.mutation({ 70 | query: (address) => ({ 71 | url: 'account/address', 72 | method: 'POST', 73 | body: address 74 | }), 75 | onQueryStarted: async (address, {dispatch, queryFulfilled}) => { 76 | const patchResult = dispatch( 77 | accountApi.util.updateQueryData('fetchAddress', undefined, (draft) => { 78 | Object.assign(draft, {...address}) 79 | }) 80 | ); 81 | 82 | try { 83 | await queryFulfilled; 84 | } catch (error) { 85 | patchResult.undo(); 86 | console.log(error); 87 | } 88 | } 89 | }) 90 | }) 91 | }); 92 | 93 | export const {useLoginMutation, useRegisterMutation, useLogoutMutation, 94 | useUserInfoQuery, useLazyUserInfoQuery, useFetchAddressQuery, 95 | useUpdateUserAddressMutation} = accountApi; -------------------------------------------------------------------------------- /client/src/features/admin/InventoryPage.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Typography } from "@mui/material"; 2 | import { useAppDispatch, useAppSelector } from "../../app/store/store" 3 | import { useFetchProductsQuery } from "../catalog/catalogApi"; 4 | import { currencyFormat } from "../../lib/util"; 5 | import { Delete, Edit } from "@mui/icons-material"; 6 | import AppPagination from "../../app/shared/components/AppPagination"; 7 | import { setPageNumber } from "../catalog/catalogSlice"; 8 | import { useState } from "react"; 9 | import ProductForm from "./ProductForm"; 10 | import { Product } from "../../app/models/product"; 11 | import { useDeleteProductMutation } from "./adminApi"; 12 | 13 | export default function InventoryPage() { 14 | const productParams = useAppSelector(state => state.catalog); 15 | const {data, refetch} = useFetchProductsQuery(productParams); 16 | const dispatch = useAppDispatch(); 17 | const [editMode, setEditMode] = useState(false); 18 | const [selectedProduct, setSelectedProduct] = useState(null); 19 | const [deleteProduct] = useDeleteProductMutation(); 20 | 21 | const handleSelectProduct = (product: Product) => { 22 | setSelectedProduct(product); 23 | setEditMode(true); 24 | } 25 | 26 | const handleDeleteProduct = async (id: number) => { 27 | try { 28 | await deleteProduct(id); 29 | refetch(); 30 | } catch (error) { 31 | console.log(error); 32 | } 33 | } 34 | 35 | if (editMode) return 41 | 42 | return ( 43 | <> 44 | 45 | Inventory 46 | 47 | 48 | 49 | 50 | 51 | 52 | # 53 | Product 54 | Price 55 | Type 56 | Brand 57 | Quantity 58 | 59 | 60 | 61 | 62 | {data && data.items.map(product => ( 63 | 69 | 70 | {product.id} 71 | 72 | 73 | 74 | {product.name} 79 | {product.name} 80 | 81 | 82 | {currencyFormat(product.price)} 83 | {product.type} 84 | {product.brand} 85 | {product.quantityInStock} 86 | 87 |
94 | 95 | {data?.pagination && data.items.length > 0 && ( 96 | dispatch(setPageNumber(page))} 99 | /> 100 | )} 101 | 102 |
103 | 104 | ) 105 | } -------------------------------------------------------------------------------- /client/src/features/admin/adminApi.ts: -------------------------------------------------------------------------------- 1 | import { createApi } from "@reduxjs/toolkit/query/react"; 2 | import { baseQueryWithErrorHandling } from "../../app/api/baseApi"; 3 | import { Product } from "../../app/models/product"; 4 | 5 | export const adminApi = createApi({ 6 | reducerPath: 'adminApi', 7 | baseQuery: baseQueryWithErrorHandling, 8 | endpoints: (builder) => ({ 9 | createProduct: builder.mutation({ 10 | query: (data: FormData) => { 11 | return { 12 | url: 'products', 13 | method: 'POST', 14 | body: data 15 | } 16 | } 17 | }), 18 | updateProduct: builder.mutation({ 19 | query: ({id, data}) => { 20 | data.append('id', id.toString()) 21 | 22 | return { 23 | url: 'products', 24 | method: 'PUT', 25 | body: data 26 | } 27 | } 28 | }), 29 | deleteProduct: builder.mutation({ 30 | query: (id: number) => { 31 | return { 32 | url: `products/${id}`, 33 | method: 'DELETE' 34 | } 35 | } 36 | }) 37 | }) 38 | }); 39 | 40 | export const {useCreateProductMutation, useUpdateProductMutation, useDeleteProductMutation} = adminApi; -------------------------------------------------------------------------------- /client/src/features/basket/BasketItem.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Grid2, IconButton, Paper, Typography } from "@mui/material" 2 | import { Item } from "../../app/models/basket" 3 | import { Add, Close, Remove } from "@mui/icons-material" 4 | import { useAddBasketItemMutation, useRemoveBasketItemMutation } from "./basketApi" 5 | import { currencyFormat } from "../../lib/util" 6 | 7 | type Props = { 8 | item: Item 9 | } 10 | 11 | export default function BasketItem({ item }: Props) { 12 | const [removeBasketItem] = useRemoveBasketItemMutation(); 13 | const [addBasketItem] = useAddBasketItemMutation(); 14 | 15 | return ( 16 | 24 | 25 | 38 | 39 | 40 | {item.name} 41 | 42 | 43 | 44 | {currencyFormat(item.price)} x {item.quantity} 45 | 46 | 47 | {currencyFormat(item.price * item.quantity)} 48 | 49 | 50 | 51 | 52 | removeBasketItem({productId: item.productId, quantity: 1})} 54 | color="error" 55 | size="small" 56 | sx={{border: 1, borderRadius: 1, minWidth: 0}} 57 | > 58 | 59 | 60 | {item.quantity} 61 | addBasketItem({product: item, quantity: 1})} 63 | color="success" 64 | size="small" 65 | sx={{border: 1, borderRadius: 1, minWidth: 0}} 66 | > 67 | 68 | 69 | 70 | 71 | 72 | removeBasketItem({productId: item.productId, quantity: item.quantity})} 74 | color='error' 75 | size="small" 76 | sx={{ 77 | border: 1, 78 | borderRadius: 1, 79 | minWidth: 0, 80 | alignSelf: 'start', 81 | mr: 1, 82 | mt: 1 83 | }} 84 | > 85 | 86 | 87 | 88 | ) 89 | } -------------------------------------------------------------------------------- /client/src/features/basket/BasketPage.tsx: -------------------------------------------------------------------------------- 1 | import { Grid2, Typography } from "@mui/material"; 2 | import { useFetchBasketQuery } from "./basketApi" 3 | import BasketItem from "./BasketItem"; 4 | import OrderSummary from "../../app/shared/components/OrderSummary"; 5 | 6 | export default function BasketPage() { 7 | const {data, isLoading} = useFetchBasketQuery(); 8 | 9 | if (isLoading) return Loading basket... 10 | 11 | if (!data || data.items.length === 0) return Your basket is empty 12 | 13 | return ( 14 | 15 | 16 | {data.items.map(item => ( 17 | 18 | ))} 19 | 20 | 21 | 22 | 23 | 24 | ) 25 | } -------------------------------------------------------------------------------- /client/src/features/basket/basketApi.ts: -------------------------------------------------------------------------------- 1 | import { createApi } from "@reduxjs/toolkit/query/react"; 2 | import { baseQueryWithErrorHandling } from "../../app/api/baseApi"; 3 | import { Basket, Item } from "../../app/models/basket"; 4 | import { Product } from "../../app/models/product"; 5 | import Cookies from 'js-cookie'; 6 | 7 | function isBasketItem(product: Product | Item): product is Item { 8 | return (product as Item).quantity !== undefined; 9 | } 10 | 11 | export const basketApi = createApi({ 12 | reducerPath: 'basketApi', 13 | baseQuery: baseQueryWithErrorHandling, 14 | tagTypes: ['Basket'], 15 | endpoints: (builder) => ({ 16 | fetchBasket: builder.query({ 17 | query: () => 'basket', 18 | providesTags: ['Basket'] 19 | }), 20 | addBasketItem: builder.mutation({ 21 | query: ({ product, quantity }) => { 22 | const productId = isBasketItem(product) ? product.productId : product.id; 23 | return { 24 | url: `basket?productId=${productId}&quantity=${quantity}`, 25 | method: 'POST' 26 | } 27 | }, 28 | onQueryStarted: async ({ product, quantity }, { dispatch, queryFulfilled }) => { 29 | let isNewBasket = false; 30 | const patchResult = dispatch( 31 | basketApi.util.updateQueryData('fetchBasket', undefined, (draft) => { 32 | const productId = isBasketItem(product) ? product.productId : product.id; 33 | 34 | if (!draft?.basketId) isNewBasket = true; 35 | 36 | if (!isNewBasket) { 37 | const existingItem = draft.items.find(item => item.productId === productId); 38 | if (existingItem) existingItem.quantity += quantity; 39 | else draft.items.push(isBasketItem(product) 40 | ? product : {...product, productId: product.id, quantity}); 41 | } 42 | }) 43 | ) 44 | 45 | try { 46 | await queryFulfilled; 47 | 48 | if (isNewBasket) dispatch(basketApi.util.invalidateTags(['Basket'])) 49 | } catch (error) { 50 | console.log(error); 51 | patchResult.undo(); 52 | } 53 | } 54 | }), 55 | removeBasketItem: builder.mutation({ 56 | query: ({ productId, quantity }) => ({ 57 | url: `basket?productId=${productId}&quantity=${quantity}`, 58 | method: 'DELETE' 59 | }), 60 | onQueryStarted: async ({ productId, quantity }, { dispatch, queryFulfilled }) => { 61 | const patchResult = dispatch( 62 | basketApi.util.updateQueryData('fetchBasket', undefined, (draft) => { 63 | const itemIndex = draft.items.findIndex(item => item.productId === productId); 64 | if (itemIndex >= 0) { 65 | draft.items[itemIndex].quantity -= quantity; 66 | if (draft.items[itemIndex].quantity <= 0) { 67 | draft.items.splice(itemIndex, 1); 68 | } 69 | } 70 | }) 71 | ) 72 | 73 | try { 74 | await queryFulfilled; 75 | } catch (error) { 76 | console.log(error); 77 | patchResult.undo(); 78 | } 79 | } 80 | }), 81 | clearBasket: builder.mutation({ 82 | queryFn: () => ({data: undefined}), 83 | onQueryStarted: async (_, {dispatch}) => { 84 | dispatch( 85 | basketApi.util.updateQueryData('fetchBasket', undefined, (draft) => { 86 | draft.items = []; 87 | draft.basketId = ''; 88 | }) 89 | ); 90 | Cookies.remove('basketId'); 91 | } 92 | }), 93 | addCoupon: builder.mutation({ 94 | query: (code: string) => ({ 95 | url: `basket/${code}`, 96 | method: 'POST' 97 | }), 98 | onQueryStarted: async (_, {dispatch, queryFulfilled}) => { 99 | const {data: updatedBasket} = await queryFulfilled; 100 | 101 | dispatch(basketApi.util.updateQueryData('fetchBasket', undefined, (draft)=> { 102 | Object.assign(draft, updatedBasket) 103 | })) 104 | } 105 | }), 106 | removeCoupon: builder.mutation({ 107 | query: () => ({ 108 | url: 'basket/remove-coupon', 109 | method: 'DELETE' 110 | }), 111 | onQueryStarted: async (_, {dispatch, queryFulfilled}) => { 112 | await queryFulfilled; 113 | 114 | dispatch(basketApi.util.updateQueryData('fetchBasket', undefined, (draft)=> { 115 | draft.coupon = null 116 | })) 117 | } 118 | }) 119 | }) 120 | }); 121 | 122 | export const { useFetchBasketQuery, useAddBasketItemMutation, 123 | useAddCouponMutation, useRemoveCouponMutation, 124 | useRemoveBasketItemMutation, useClearBasketMutation } = basketApi; -------------------------------------------------------------------------------- /client/src/features/catalog/Catalog.tsx: -------------------------------------------------------------------------------- 1 | import { Grid2, Typography } from "@mui/material"; 2 | import ProductList from "./ProductList"; 3 | import { useFetchFiltersQuery, useFetchProductsQuery } from "./catalogApi"; 4 | import Filters from "./Filters"; 5 | import { useAppDispatch, useAppSelector } from "../../app/store/store"; 6 | import AppPagination from "../../app/shared/components/AppPagination"; 7 | import { setPageNumber } from "./catalogSlice"; 8 | 9 | export default function Catalog() { 10 | const productParams = useAppSelector(state => state.catalog); 11 | const {data, isLoading} = useFetchProductsQuery(productParams); 12 | const {data: filtersData, isLoading: filtersLoading} = useFetchFiltersQuery(); 13 | const dispatch = useAppDispatch(); 14 | 15 | if (isLoading || !data || filtersLoading || !filtersData) return
Loading...
16 | 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | {data.items && data.items.length > 0 ? ( 24 | <> 25 | 26 | { 29 | dispatch(setPageNumber(page)); 30 | window.scrollTo({top: 0, behavior: 'smooth'}) 31 | }} 32 | /> 33 | 34 | ) : ( 35 | There are no results for this filter 36 | )} 37 | 38 | 39 | ) 40 | } -------------------------------------------------------------------------------- /client/src/features/catalog/Filters.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Paper } from "@mui/material"; 2 | import Search from "./Search"; 3 | import RadioButtonGroup from "../../app/shared/components/RadioButtonGroup"; 4 | import { useAppDispatch, useAppSelector } from "../../app/store/store"; 5 | import { resetParams, setBrands, setOrderBy, setTypes } from "./catalogSlice"; 6 | import CheckboxButtons from "../../app/shared/components/CheckboxButtons"; 7 | 8 | const sortOptions = [ 9 | { value: 'name', label: 'Alphabetical' }, 10 | { value: 'priceDesc', label: 'Price: High to low' }, 11 | { value: 'price', label: 'Price: Low to high' }, 12 | ] 13 | 14 | type Props = { 15 | filtersData: {brands: string[]; types: string[];} 16 | } 17 | 18 | export default function Filters({filtersData: data}: Props) { 19 | 20 | const { orderBy, types, brands } = useAppSelector(state => state.catalog); 21 | const dispatch = useAppDispatch(); 22 | 23 | return ( 24 | 25 | 26 | 27 | 28 | 29 | dispatch(setOrderBy(e.target.value))} 33 | /> 34 | 35 | 36 | dispatch(setBrands(items))} 40 | /> 41 | 42 | 43 | dispatch(setTypes(items))} 47 | /> 48 | 49 | 50 | 51 | ) 52 | } -------------------------------------------------------------------------------- /client/src/features/catalog/ProductCard.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Card, CardActions, CardContent, CardMedia, Typography } from "@mui/material" 2 | import { Product } from "../../app/models/product" 3 | import { Link } from "react-router-dom" 4 | import { useAddBasketItemMutation } from "../basket/basketApi" 5 | import { currencyFormat } from "../../lib/util" 6 | 7 | type Props = { 8 | product: Product 9 | } 10 | 11 | export default function ProductCard({ product }: Props) { 12 | const [addBasketItem, {isLoading}] = useAddBasketItemMutation(); 13 | return ( 14 | 24 | 29 | 30 | 34 | {product.name} 35 | 36 | 40 | {currencyFormat(product.price)} 41 | 42 | 43 | 46 | 50 | 51 | 52 | 53 | ) 54 | } -------------------------------------------------------------------------------- /client/src/features/catalog/ProductDetails.tsx: -------------------------------------------------------------------------------- 1 | import { useParams } from "react-router-dom" 2 | import { Button, Divider, Grid2, Table, TableBody, TableCell, TableContainer, TableRow, TextField, Typography } from "@mui/material"; 3 | import { useFetchProductDetailsQuery } from "./catalogApi"; 4 | import { useAddBasketItemMutation, useFetchBasketQuery, useRemoveBasketItemMutation } from "../basket/basketApi"; 5 | import { ChangeEvent, useEffect, useState } from "react"; 6 | 7 | export default function ProductDetails() { 8 | const { id } = useParams(); 9 | const [removeBasketItem] = useRemoveBasketItemMutation(); 10 | const [addBasketItem] = useAddBasketItemMutation(); 11 | const {data: basket} = useFetchBasketQuery(); 12 | const item = basket?.items.find(x => x.productId === +id!); 13 | const [quantity, setQuantity] = useState(0); 14 | 15 | useEffect(() => { 16 | if (item) setQuantity(item.quantity); 17 | }, [item]); 18 | 19 | const {data: product, isLoading} = useFetchProductDetailsQuery(id ? +id : 0) 20 | 21 | if (!product || isLoading) return
Loading...
22 | 23 | const handleUpdateBasket = () => { 24 | const updatedQuantity = item ? Math.abs(quantity - item.quantity) : quantity; 25 | if (!item || quantity > item.quantity) { 26 | addBasketItem({product, quantity: updatedQuantity}) 27 | } else { 28 | removeBasketItem({productId: product.id, quantity: updatedQuantity}) 29 | } 30 | } 31 | 32 | const handleInputChange = (event: ChangeEvent) => { 33 | const value = +event.currentTarget.value; 34 | 35 | if (value >= 0) setQuantity(value) 36 | } 37 | 38 | const productDetails = [ 39 | { label: 'Name', value: product.name }, 40 | { label: 'Description', value: product.description }, 41 | { label: 'Type', value: product.type }, 42 | { label: 'Brand', value: product.brand }, 43 | { label: 'Quantity in stock', value: product.quantityInStock }, 44 | ] 45 | 46 | return ( 47 | 48 | 49 | {product.name} 50 | 51 | 52 | {product.name} 53 | 54 | ${(product.price / 100).toFixed(2)} 55 | 56 | 59 | 60 | {productDetails.map((detail, index) => ( 61 | 62 | {detail.label} 63 | {detail.value} 64 | 65 | ))} 66 | 67 | 68 |
69 |
70 | 71 | 72 | 80 | 81 | 82 | 93 | 94 | 95 |
96 |
97 | ) 98 | } 99 | -------------------------------------------------------------------------------- /client/src/features/catalog/ProductList.tsx: -------------------------------------------------------------------------------- 1 | import { Grid2 } from "@mui/material" 2 | import { Product } from "../../app/models/product" 3 | import ProductCard from "./ProductCard" 4 | 5 | type Props = { 6 | products: Product[] 7 | } 8 | 9 | export default function ProductList({ products }: Props) { 10 | return ( 11 | 12 | {products.map(product => ( 13 | 14 | 15 | 16 | 17 | ))} 18 | 19 | ) 20 | } -------------------------------------------------------------------------------- /client/src/features/catalog/Search.tsx: -------------------------------------------------------------------------------- 1 | import { debounce, TextField } from "@mui/material"; 2 | import { useAppDispatch, useAppSelector } from "../../app/store/store"; 3 | import { setSearchTerm } from "./catalogSlice"; 4 | import { useEffect, useState } from "react"; 5 | 6 | export default function Search() { 7 | const {searchTerm} = useAppSelector(state => state.catalog); 8 | const dispatch = useAppDispatch(); 9 | const [term, setTerm] = useState(searchTerm); 10 | 11 | useEffect(() => { 12 | setTerm(searchTerm) 13 | }, [searchTerm]); 14 | 15 | const debouncedSearch = debounce(event => { 16 | dispatch(setSearchTerm(event.target.value)) 17 | }, 500) 18 | 19 | return ( 20 | { 27 | setTerm(e.target.value); 28 | debouncedSearch(e); 29 | }} 30 | /> 31 | ) 32 | } -------------------------------------------------------------------------------- /client/src/features/catalog/catalogApi.ts: -------------------------------------------------------------------------------- 1 | import { createApi } from "@reduxjs/toolkit/query/react"; 2 | import { Product } from "../../app/models/product"; 3 | import { baseQueryWithErrorHandling } from "../../app/api/baseApi"; 4 | import { ProductParams } from "../../app/models/productParams"; 5 | import { filterEmptyValues } from "../../lib/util"; 6 | import { Pagination } from "../../app/models/pagination"; 7 | 8 | export const catalogApi = createApi({ 9 | reducerPath: 'catalogApi', 10 | baseQuery: baseQueryWithErrorHandling, 11 | endpoints: (builder) => ({ 12 | fetchProducts: builder.query<{items: Product[], pagination: Pagination}, ProductParams>({ 13 | query: (productParams) => { 14 | return { 15 | url: 'products', 16 | params: filterEmptyValues(productParams) 17 | } 18 | }, 19 | transformResponse: (items: Product[], meta) => { 20 | const paginationHeader = meta?.response?.headers.get('Pagination'); 21 | const pagination = paginationHeader ? JSON.parse(paginationHeader) : null; 22 | return {items, pagination} 23 | } 24 | }), 25 | fetchProductDetails: builder.query({ 26 | query: (productId) => `products/${productId}` 27 | }), 28 | fetchFilters: builder.query<{ brands: string[], types: string[] }, void>({ 29 | query: () => 'products/filters' 30 | }) 31 | }) 32 | }); 33 | 34 | export const { useFetchProductDetailsQuery, useFetchProductsQuery, useFetchFiltersQuery } 35 | = catalogApi; -------------------------------------------------------------------------------- /client/src/features/catalog/catalogSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | import { ProductParams } from "../../app/models/productParams"; 3 | 4 | const initialState: ProductParams = { 5 | pageNumber: 1, 6 | pageSize: 8, 7 | types: [], 8 | brands: [], 9 | searchTerm: '', 10 | orderBy: 'name' 11 | } 12 | 13 | export const catalogSlice = createSlice({ 14 | name: 'catalogSlice', 15 | initialState, 16 | reducers: { 17 | setPageNumber(state, action) { 18 | state.pageNumber = action.payload 19 | }, 20 | setPageSize(state, action) { 21 | state.pageSize = action.payload 22 | }, 23 | setOrderBy(state, action) { 24 | state.orderBy = action.payload 25 | state.pageNumber = 1; 26 | }, 27 | setTypes(state, action) { 28 | state.types = action.payload 29 | state.pageNumber = 1; 30 | }, 31 | setBrands(state, action) { 32 | state.brands = action.payload 33 | state.pageNumber = 1; 34 | }, 35 | setSearchTerm(state, action) { 36 | state.searchTerm = action.payload 37 | state.pageNumber = 1; 38 | }, 39 | resetParams() { 40 | return initialState; 41 | } 42 | } 43 | }); 44 | 45 | export const {setBrands, setOrderBy, setPageNumber, setPageSize, 46 | setSearchTerm, setTypes, resetParams} 47 | = catalogSlice.actions; -------------------------------------------------------------------------------- /client/src/features/checkout/CheckoutPage.tsx: -------------------------------------------------------------------------------- 1 | import { Grid2, Typography } from "@mui/material"; 2 | import OrderSummary from "../../app/shared/components/OrderSummary"; 3 | import CheckoutStepper from "./CheckoutStepper"; 4 | import { loadStripe, StripeElementsOptions } from "@stripe/stripe-js"; 5 | import { Elements } from "@stripe/react-stripe-js"; 6 | import { useFetchBasketQuery } from "../basket/basketApi"; 7 | import { useEffect, useMemo, useRef } from "react"; 8 | import { useCreatePaymentIntentMutation } from "./checkoutApi"; 9 | import { useAppSelector } from "../../app/store/store"; 10 | 11 | const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PK); 12 | 13 | export default function CheckoutPage() { 14 | const { data: basket } = useFetchBasketQuery(); 15 | const [createPaymentIntent, {isLoading}] = useCreatePaymentIntentMutation(); 16 | const created = useRef(false); 17 | const {darkMode} = useAppSelector(state => state.ui); 18 | 19 | useEffect(() => { 20 | if (!created.current) createPaymentIntent(); 21 | created.current = true; 22 | }, [createPaymentIntent]) 23 | 24 | const options: StripeElementsOptions | undefined = useMemo(() => { 25 | if (!basket?.clientSecret) return undefined; 26 | return { 27 | clientSecret: basket.clientSecret, 28 | appearance: { 29 | labels: 'floating', 30 | theme: darkMode ? 'night' : 'stripe' 31 | } 32 | } 33 | }, [basket?.clientSecret, darkMode]) 34 | 35 | return ( 36 | 37 | 38 | {!stripePromise || !options || isLoading ? ( 39 | Loading checkout... 40 | ) : ( 41 | 42 | 43 | 44 | )} 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | ) 53 | } -------------------------------------------------------------------------------- /client/src/features/checkout/CheckoutSuccess.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Container, Divider, Paper, Typography } from "@mui/material"; 2 | import { Link, useLocation } from "react-router-dom"; 3 | import { Order } from "../../app/models/order"; 4 | import { currencyFormat, formatAddressString, formatPaymentString } from "../../lib/util"; 5 | 6 | export default function CheckoutSuccess() { 7 | const { state } = useLocation(); 8 | const order = state.data as Order; 9 | 10 | if (!order) return Problem accessing the order 11 | 12 | 13 | 14 | return ( 15 | 16 | <> 17 | 18 | Thanks for your fake order! 19 | 20 | 21 | Your order #{order.id} will never be processed as this is a fake shop. 22 | 23 | 24 | 25 | 26 | 27 | Order date 28 | 29 | 30 | {order.orderDate} 31 | 32 | 33 | 34 | 35 | 36 | Payment method 37 | 38 | 39 | {formatPaymentString(order.paymentSummary)} 40 | 41 | 42 | 43 | 44 | 45 | Shipping address 46 | 47 | 48 | {formatAddressString(order.shippingAddress)} 49 | 50 | 51 | 52 | 53 | 54 | Amount 55 | 56 | 57 | {currencyFormat(order.total)} 58 | 59 | 60 | 61 | 62 | 63 | 66 | 69 | 70 | 71 | 72 | ) 73 | } -------------------------------------------------------------------------------- /client/src/features/checkout/Review.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Divider, Table, TableBody, TableCell, TableContainer, TableRow, Typography } from "@mui/material"; 2 | import { currencyFormat } from "../../lib/util"; 3 | import { ConfirmationToken } from "@stripe/stripe-js"; 4 | import { useBasket } from "../../lib/hooks/useBasket"; 5 | 6 | type Props = { 7 | confirmationToken: ConfirmationToken | null; 8 | } 9 | 10 | export default function Review({confirmationToken}: Props) { 11 | const {basket} = useBasket(); 12 | 13 | const addressString = () => { 14 | if (!confirmationToken?.shipping) return ''; 15 | const {name, address} = confirmationToken.shipping; 16 | return `${name}, ${address?.line1}, ${address?.city}, ${address?.state}, 17 | ${address?.postal_code}, ${address?.country}` 18 | } 19 | 20 | const paymentString = () => { 21 | if (!confirmationToken?.payment_method_preview.card) return ''; 22 | const {card} = confirmationToken.payment_method_preview; 23 | 24 | return `${card.brand.toUpperCase()}, **** **** **** ${card.last4}, 25 | Exp: ${card.exp_month}/${card.exp_year}` 26 | } 27 | 28 | return ( 29 |
30 | 31 | 32 | Billing and delivery information 33 | 34 |
35 | 36 | Shipping address 37 | 38 | 39 | {addressString()} 40 | 41 | 42 | 43 | Payment details 44 | 45 | 46 | {paymentString()} 47 | 48 |
49 |
50 | 51 | 52 | 53 | 54 | 55 | 56 | {basket?.items.map((item) => ( 57 | 59 | 60 | 61 | {item.name} 65 | 66 | {item.name} 67 | 68 | 69 | 70 | 71 | x {item.quantity} 72 | 73 | 74 | {currencyFormat(item.price)} 75 | 76 | 77 | ))} 78 | 79 |
80 |
81 |
82 |
83 | ) 84 | } -------------------------------------------------------------------------------- /client/src/features/checkout/checkoutApi.ts: -------------------------------------------------------------------------------- 1 | import { createApi } from "@reduxjs/toolkit/query/react"; 2 | import { baseQueryWithErrorHandling } from "../../app/api/baseApi"; 3 | import { Basket } from "../../app/models/basket"; 4 | import { basketApi } from "../basket/basketApi"; 5 | 6 | export const checkoutApi = createApi({ 7 | reducerPath: 'checkoutApi', 8 | baseQuery: baseQueryWithErrorHandling, 9 | endpoints: (builder) => ({ 10 | createPaymentIntent: builder.mutation({ 11 | query: () => { 12 | return { 13 | url: 'payments', 14 | method: 'POST' 15 | } 16 | }, 17 | onQueryStarted: async (_, { dispatch, queryFulfilled }) => { 18 | try { 19 | const { data } = await queryFulfilled; 20 | dispatch( 21 | basketApi.util.updateQueryData('fetchBasket', undefined, (draft) => { 22 | draft.clientSecret = data.clientSecret 23 | }) 24 | ) 25 | } catch (error) { 26 | console.log('Payment intent creation failed: ', error) 27 | } 28 | } 29 | }) 30 | }) 31 | }); 32 | 33 | export const {useCreatePaymentIntentMutation} = checkoutApi; -------------------------------------------------------------------------------- /client/src/features/contact/ContactPage.tsx: -------------------------------------------------------------------------------- 1 | import { decrement, increment } from "./counterReducer" 2 | import { Button, ButtonGroup, Typography } from "@mui/material"; 3 | import { useAppDispatch, useAppSelector } from "../../app/store/store"; 4 | 5 | export default function ContactPage() { 6 | const {data} = useAppSelector(state => state.counter); 7 | const dispatch = useAppDispatch(); 8 | 9 | return ( 10 | <> 11 | 12 | Contact page 13 | 14 | 15 | The data is: {data} 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ) 24 | } -------------------------------------------------------------------------------- /client/src/features/contact/counterReducer.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit" 2 | 3 | export type CounterState = { 4 | data: number 5 | } 6 | 7 | const initialState: CounterState = { 8 | data: 42 9 | } 10 | 11 | export const counterSlice = createSlice({ 12 | name: 'counter', 13 | initialState, 14 | reducers: { 15 | increment: (state, action) => { 16 | state.data += action.payload 17 | }, 18 | decrement: (state, action) => { 19 | state.data -= action.payload 20 | } 21 | } 22 | }) 23 | 24 | export const {increment, decrement} = counterSlice.actions; 25 | 26 | 27 | export function incrementLegacy(amount = 1) { 28 | return { 29 | type: 'increment', 30 | payload: amount 31 | } 32 | } 33 | 34 | export function decrementLegacy(amount = 1) { 35 | return { 36 | type: 'decrement', 37 | payload: amount 38 | } 39 | } 40 | 41 | export default function counterReducer(state = initialState, 42 | action: {type: string, payload: number}) { 43 | switch (action.type) { 44 | case 'increment': 45 | return { 46 | ...state, 47 | data: state.data + action.payload 48 | } 49 | case 'decrement': 50 | return { 51 | ...state, 52 | data: state.data - action.payload 53 | } 54 | default: 55 | return state; 56 | } 57 | } -------------------------------------------------------------------------------- /client/src/features/home/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Typography } from "@mui/material"; 2 | import { Link } from "react-router-dom"; 3 | 4 | export default function HomePage() { 5 | return ( 6 | 7 | 12 | ski resort image 25 | 33 | 40 | Welcome to Restore! 41 | 42 | 60 | 61 | 62 | 63 | ) 64 | } -------------------------------------------------------------------------------- /client/src/features/orders/OrdersPage.tsx: -------------------------------------------------------------------------------- 1 | import { Container, Paper, Table, TableBody, TableCell, TableHead, TableRow, Typography } from "@mui/material"; 2 | import { useFetchOrdersQuery } from "./orderApi" 3 | import { useNavigate } from "react-router-dom"; 4 | import { format } from "date-fns"; 5 | import { currencyFormat } from "../../lib/util"; 6 | 7 | export default function OrdersPage() { 8 | const {data: orders, isLoading} = useFetchOrdersQuery(); 9 | const navigate = useNavigate(); 10 | 11 | if (isLoading) return Loading orders... 12 | 13 | if (!orders) return No orders available 14 | 15 | return ( 16 | 17 | 18 | My orders 19 | 20 | 21 | 22 | 23 | 24 | Order 25 | Date 26 | Total 27 | Status 28 | 29 | 30 | 31 | {orders.map(order => ( 32 | navigate(`/orders/${order.id}`)} 36 | style={{cursor: 'pointer'}} 37 | > 38 | # {order.id} 39 | {format(order.orderDate, 'dd MMM yyyy')} 40 | {currencyFormat(order.total)} 41 | {order.orderStatus} 42 | 43 | ))} 44 | 45 |
46 |
47 |
48 | ) 49 | } -------------------------------------------------------------------------------- /client/src/features/orders/orderApi.ts: -------------------------------------------------------------------------------- 1 | import { createApi } from "@reduxjs/toolkit/query/react"; 2 | import { baseQueryWithErrorHandling } from "../../app/api/baseApi"; 3 | import { CreateOrder, Order } from "../../app/models/order"; 4 | 5 | export const orderApi = createApi({ 6 | reducerPath: 'orderApi', 7 | baseQuery: baseQueryWithErrorHandling, 8 | tagTypes: ['Orders'], 9 | endpoints: (builder) => ({ 10 | fetchOrders: builder.query({ 11 | query: () => 'orders', 12 | providesTags: ['Orders'] 13 | }), 14 | fetchOrderDetailed: builder.query({ 15 | query: (id) => ({ 16 | url: `orders/${id}` 17 | }) 18 | }), 19 | createOrder: builder.mutation({ 20 | query: (order) => ({ 21 | url: 'orders', 22 | method: 'POST', 23 | body: order 24 | }), 25 | onQueryStarted: async (_, {dispatch, queryFulfilled}) => { 26 | await queryFulfilled; 27 | dispatch(orderApi.util.invalidateTags(['Orders'])) 28 | } 29 | }) 30 | }) 31 | }) 32 | 33 | export const {useFetchOrdersQuery, useFetchOrderDetailedQuery, useCreateOrderMutation} 34 | = orderApi; -------------------------------------------------------------------------------- /client/src/lib/hooks/useBasket.ts: -------------------------------------------------------------------------------- 1 | import { Item } from "../../app/models/basket"; 2 | import { useClearBasketMutation, useFetchBasketQuery } from "../../features/basket/basketApi"; 3 | 4 | export const useBasket = () => { 5 | const {data: basket} = useFetchBasketQuery(); 6 | const [clearBasket] = useClearBasketMutation(); 7 | 8 | const subtotal = basket?.items.reduce((sum: number, item: Item) => sum + item.quantity * item.price, 0) ?? 0; 9 | const deliveryFee = subtotal > 10000 ? 0 : 500; 10 | 11 | 12 | let discount = 0; 13 | 14 | if (basket?.coupon) { 15 | if (basket.coupon.amountOff) { 16 | discount = basket.coupon.amountOff 17 | } else if (basket.coupon.percentOff) { 18 | discount = Math.round((subtotal * (basket.coupon.percentOff / 100)) * 100) / 100; 19 | } 20 | } 21 | 22 | const total = Math.round((subtotal - discount + deliveryFee) * 100) / 100; 23 | 24 | return {basket, subtotal, deliveryFee, discount, total, clearBasket} 25 | } -------------------------------------------------------------------------------- /client/src/lib/schemas/createProductSchema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | const fileSchema = z.instanceof(File).refine(file => file.size > 0, { 4 | message: 'A file must be uploaded' 5 | }).transform(file => ({ 6 | ...file, 7 | preview: URL.createObjectURL(file) 8 | })) 9 | 10 | export const createProductSchema = z.object({ 11 | name: z.string({required_error: 'Name of product is required'}), 12 | description: z.string({required_error: 'Description is required'}).min(10, { 13 | message: 'Description must be at least 10 characters' 14 | }), 15 | price: z.coerce.number({required_error: 'Price is required'}) 16 | .min(100, 'Price must be at least $1.00'), 17 | type: z.string({required_error: 'Type is required'}), 18 | brand: z.string({required_error: 'Brand is required'}), 19 | quantityInStock: z.coerce.number({required_error: 'Quantity is required'}) 20 | .min(1, 'Quantity must be at least 1'), 21 | pictureUrl: z.string().optional(), 22 | file: fileSchema.optional() 23 | }).refine((data) => data.pictureUrl || data.file, { 24 | message: 'Please provide an image', 25 | path: ['file'] 26 | }) 27 | 28 | export type CreateProductSchema = z.infer; -------------------------------------------------------------------------------- /client/src/lib/schemas/loginSchema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const loginSchema = z.object({ 4 | email: z.string().email(), 5 | password: z.string().min(6, { 6 | message: 'Password must be at least 6 characters' 7 | }) 8 | }); 9 | 10 | export type LoginSchema = z.infer; -------------------------------------------------------------------------------- /client/src/lib/schemas/registerSchema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | const passwordValidation = new RegExp( 4 | /(?=^.{6,10}$)(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[!@#$%^&*()_+}{":;'?/>.<,])(?!.*\s).*$/ 5 | ) 6 | 7 | export const registerSchema = z.object({ 8 | email: z.string().email(), 9 | password: z.string().regex(passwordValidation, { 10 | message: 'Password must contain 1 lowercase character, 1 uppercase character, 1 number, 1 special and be 6-10 characters' 11 | }) 12 | }); 13 | 14 | export type RegisterSchema = z.infer; -------------------------------------------------------------------------------- /client/src/lib/util.ts: -------------------------------------------------------------------------------- 1 | import { FieldValues, Path, UseFormSetError } from "react-hook-form" 2 | import { PaymentSummary, ShippingAddress } from "../app/models/order" 3 | 4 | export function currencyFormat(amount: number) { 5 | return '$' + (amount / 100).toFixed(2) 6 | } 7 | 8 | export function filterEmptyValues(values: object) { 9 | return Object.fromEntries( 10 | Object.entries(values).filter( 11 | ([, value]) => value !== '' && value !== null 12 | && value !== undefined && value.length !== 0 13 | ) 14 | ) 15 | } 16 | 17 | export const formatAddressString = (address: ShippingAddress) => { 18 | return `${address?.name}, ${address?.line1}, ${address?.city}, ${address?.state}, 19 | ${address?.postal_code}, ${address?.country}` 20 | } 21 | 22 | export const formatPaymentString = (card: PaymentSummary) => { 23 | return `${card?.brand?.toUpperCase()}, **** **** **** ${card?.last4}, 24 | Exp: ${card?.exp_month}/${card?.exp_year}` 25 | } 26 | 27 | export function handleApiError( 28 | error: unknown, 29 | setError: UseFormSetError, 30 | fieldNames: Path[] 31 | ) { 32 | const apiError = (error as {message: string}) || {}; 33 | 34 | if (apiError.message && typeof apiError.message === 'string') { 35 | const errorArray = apiError.message.split(','); 36 | 37 | errorArray.forEach(e => { 38 | const matchedField = fieldNames.find(fieldName => 39 | e.toLowerCase().includes(fieldName.toString().toLowerCase())); 40 | 41 | if (matchedField) setError(matchedField, {message: e.trim()}); 42 | }) 43 | } 44 | } -------------------------------------------------------------------------------- /client/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import './app/layout/styles.css' 4 | import '@fontsource/roboto/300.css'; 5 | import '@fontsource/roboto/400.css'; 6 | import '@fontsource/roboto/500.css'; 7 | import '@fontsource/roboto/700.css'; 8 | import { RouterProvider } from 'react-router-dom'; 9 | import { router } from './app/routes/Routes'; 10 | import { Provider } from 'react-redux'; 11 | import { store } from './app/store/store'; 12 | import { ToastContainer } from 'react-toastify'; 13 | import 'react-toastify/dist/ReactToastify.css'; 14 | 15 | createRoot(document.getElementById('root')!).render( 16 | 17 | 18 | 19 | 20 | 21 | 22 | , 23 | ) 24 | -------------------------------------------------------------------------------- /client/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /client/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "Bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true 24 | }, 25 | "include": ["src"] 26 | } 27 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /client/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "Bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react-swc' 3 | import mkcert from 'vite-plugin-mkcert' 4 | 5 | // https://vite.dev/config/ 6 | export default defineConfig({ 7 | build: { 8 | outDir: '../API/wwwroot', 9 | chunkSizeWarningLimit: 1024, 10 | emptyOutDir: true 11 | }, 12 | server: { 13 | port: 3000 14 | }, 15 | plugins: [react(), mkcert()], 16 | }) 17 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | sql: 3 | image: mcr.microsoft.com/mssql/server:2022-latest 4 | environment: 5 | ACCEPT_EULA: "Y" 6 | MSSQL_SA_PASSWORD: "Password@1" 7 | ports: 8 | - "1433:1433" 9 | volumes: 10 | - sql-data:/var/opt/mssql 11 | platform: "linux/amd64" 12 | volumes: 13 | sql-data: 14 | --------------------------------------------------------------------------------