├── .gitignore ├── .vscode ├── launch.json └── tasks.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 │ ├── LoginDto.cs │ ├── OrderDto.cs │ ├── OrderItemDto.cs │ ├── RegisterDto.cs │ ├── UpdateProductDto.cs │ └── UserDto.cs ├── Data │ ├── DbInitializer.cs │ ├── Migrations │ │ ├── 20211003080020_PostGresInitial.Designer.cs │ │ ├── 20211003080020_PostGresInitial.cs │ │ ├── 20211005031543_PublicIdAdded.Designer.cs │ │ ├── 20211005031543_PublicIdAdded.cs │ │ └── StoreContextModelSnapshot.cs │ └── StoreContext.cs ├── Entities │ ├── Address.cs │ ├── Basket.cs │ ├── BasketItem.cs │ ├── OrderAggregate │ │ ├── Order.cs │ │ ├── OrderItem.cs │ │ ├── OrderStatus.cs │ │ ├── ProductItemOrdered.cs │ │ └── ShippingAddress.cs │ ├── Product.cs │ ├── Role.cs │ ├── User.cs │ └── UserAddress.cs ├── Extensions │ ├── BasketExtensions.cs │ ├── HttpExtensions.cs │ ├── OrderExtensions.cs │ └── ProductExtensions.cs ├── Middleware │ └── ExceptionMiddleware.cs ├── Program.cs ├── Properties │ └── launchSettings.json ├── RequestHelpers │ ├── MappingProfiles.cs │ ├── MetaData.cs │ ├── PagedList.cs │ ├── PaginationParams.cs │ └── ProductParams.cs ├── Services │ ├── ImageService.cs │ ├── PaymentService.cs │ └── TokenService.cs ├── Startup.cs ├── WeatherForecast.cs ├── appsettings.Development.json ├── store.db └── wwwroot │ ├── asset-manifest.json │ ├── favicon.ico │ ├── images │ ├── hero1.jpg │ ├── hero2.jpg │ ├── hero3.jpg │ ├── logo.png │ ├── placeholder.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 │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ ├── robots.txt │ └── static │ ├── css │ ├── 2.31549f77.chunk.css │ ├── 2.31549f77.chunk.css.map │ ├── main.afd7172b.chunk.css │ └── main.afd7172b.chunk.css.map │ ├── js │ ├── 2.8d234b4e.chunk.js │ ├── 2.8d234b4e.chunk.js.LICENSE.txt │ ├── 2.8d234b4e.chunk.js.map │ ├── 3.831e55aa.chunk.js │ ├── 3.831e55aa.chunk.js.map │ ├── main.885616a9.chunk.js │ ├── main.885616a9.chunk.js.map │ ├── runtime-main.14d8c866.js │ └── runtime-main.14d8c866.js.map │ └── media │ ├── slick.2630a3e3.svg │ ├── slick.29518378.woff │ ├── slick.a4e97f5a.eot │ └── slick.c94f7671.ttf ├── README.md ├── ReStore.sln └── client ├── .env.development ├── .env.production ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── images │ ├── hero1.jpg │ ├── hero2.jpg │ ├── hero3.jpg │ ├── logo.png │ ├── placeholder.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 ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── src ├── app │ ├── api │ │ └── agent.ts │ ├── components │ │ ├── AppCheckbox.tsx │ │ ├── AppDropzone.tsx │ │ ├── AppPagination.tsx │ │ ├── AppSelectList.tsx │ │ ├── AppTextInput.tsx │ │ ├── CheckboxButtons.tsx │ │ └── RadioButtonGroup.tsx │ ├── context │ │ └── StoreContext.tsx │ ├── errors │ │ ├── NotFound.tsx │ │ └── ServerError.tsx │ ├── hooks │ │ └── useProducts.tsx │ ├── layout │ │ ├── App.tsx │ │ ├── Header.tsx │ │ ├── LoadingComponent.tsx │ │ ├── PrivateRoute.tsx │ │ ├── SignedInMenu.tsx │ │ └── styles.css │ ├── models │ │ ├── basket.ts │ │ ├── order.ts │ │ ├── pagination.ts │ │ ├── product.ts │ │ └── user.ts │ ├── store │ │ └── configureStore.ts │ └── util │ │ └── util.ts ├── features │ ├── about │ │ └── AboutPage.tsx │ ├── account │ │ ├── Login.tsx │ │ ├── Register.tsx │ │ └── accountSlice.ts │ ├── admin │ │ ├── Inventory.tsx │ │ ├── ProductForm.tsx │ │ └── productValidation.ts │ ├── basket │ │ ├── BasketPage.tsx │ │ ├── BasketSummary.tsx │ │ ├── BasketTable.tsx │ │ └── basketSlice.ts │ ├── catalog │ │ ├── Catalog.tsx │ │ ├── ProductCard.tsx │ │ ├── ProductCardSkeleton.tsx │ │ ├── ProductDetails.tsx │ │ ├── ProductList.tsx │ │ ├── ProductSearch.tsx │ │ └── catalogSlice.ts │ ├── checkout │ │ ├── AddressForm.tsx │ │ ├── CheckoutPage.tsx │ │ ├── CheckoutWrapper.tsx │ │ ├── PaymentForm.tsx │ │ ├── Review.tsx │ │ ├── StripeInput.tsx │ │ └── checkoutValidation.ts │ ├── contact │ │ ├── ContactPage.tsx │ │ ├── counterReducer.ts │ │ └── counterSlice.ts │ ├── home │ │ └── HomePage.tsx │ └── orders │ │ ├── OrderDetailed.tsx │ │ └── Orders.tsx ├── index.tsx ├── react-app-env.d.ts ├── reportWebVitals.ts └── setupTests.ts └── tsconfig.json /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | // Use IntelliSense to find out which attributes exist for C# debugging 6 | // Use hover for the description of the existing attributes 7 | // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md 8 | "name": ".NET Core Launch (web)", 9 | "type": "coreclr", 10 | "request": "launch", 11 | "preLaunchTask": "build", 12 | // If you have changed target frameworks, make sure to update the program path. 13 | "program": "${workspaceFolder}/API/bin/Debug/net5.0/API.dll", 14 | "args": [], 15 | "cwd": "${workspaceFolder}/API", 16 | "stopAtEntry": false, 17 | // Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser 18 | "serverReadyAction": { 19 | "action": "openExternally", 20 | "pattern": "\\bNow listening on:\\s+(https?://\\S+)" 21 | }, 22 | "env": { 23 | "ASPNETCORE_ENVIRONMENT": "Development" 24 | }, 25 | "sourceFileMap": { 26 | "/Views": "${workspaceFolder}/Views" 27 | } 28 | }, 29 | { 30 | "name": ".NET Core Attach", 31 | "type": "coreclr", 32 | "request": "attach" 33 | }, 34 | { 35 | "type": "pwa-chrome", 36 | "request": "launch", 37 | "name": "Launch Chrome against localhost", 38 | "url": "http://localhost:3000", 39 | "webRoot": "${workspaceFolder}" 40 | } 41 | ] 42 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "command": "dotnet", 7 | "type": "process", 8 | "args": [ 9 | "build", 10 | "${workspaceFolder}/API/API.csproj", 11 | "/property:GenerateFullPaths=true", 12 | "/consoleloggerparameters:NoSummary" 13 | ], 14 | "problemMatcher": "$msCompile" 15 | }, 16 | { 17 | "label": "publish", 18 | "command": "dotnet", 19 | "type": "process", 20 | "args": [ 21 | "publish", 22 | "${workspaceFolder}/API/API.csproj", 23 | "/property:GenerateFullPaths=true", 24 | "/consoleloggerparameters:NoSummary" 25 | ], 26 | "problemMatcher": "$msCompile" 27 | }, 28 | { 29 | "label": "watch", 30 | "command": "dotnet", 31 | "type": "process", 32 | "args": [ 33 | "watch", 34 | "run", 35 | "${workspaceFolder}/API/API.csproj", 36 | "/property:GenerateFullPaths=true", 37 | "/consoleloggerparameters:NoSummary" 38 | ], 39 | "problemMatcher": "$msCompile" 40 | } 41 | ] 42 | } -------------------------------------------------------------------------------- /API/API.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net5.0 5 | c82cace8-1860-40a9-bed6-5dc3d6faf4dd 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | runtime; build; native; contentfiles; analyzers; buildtransitive 15 | all 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /API/Controllers/AccountController.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Threading.Tasks; 3 | using API.Data; 4 | using API.DTOs; 5 | using API.Entities; 6 | using API.Extensions; 7 | using API.Services; 8 | using Microsoft.AspNetCore.Authorization; 9 | using Microsoft.AspNetCore.Identity; 10 | using Microsoft.AspNetCore.Mvc; 11 | using Microsoft.EntityFrameworkCore; 12 | 13 | namespace API.Controllers 14 | { 15 | public class AccountController : BaseApiController 16 | { 17 | private readonly UserManager _userManager; 18 | private readonly TokenService _tokenService; 19 | private readonly StoreContext _context; 20 | public AccountController(UserManager userManager, TokenService tokenService, StoreContext context) 21 | { 22 | _context = context; 23 | _tokenService = tokenService; 24 | _userManager = userManager; 25 | } 26 | 27 | [HttpPost("login")] 28 | public async Task> Login(LoginDto loginDto) 29 | { 30 | var user = await _userManager.FindByNameAsync(loginDto.Username); 31 | 32 | if (user == null || !await _userManager.CheckPasswordAsync(user, loginDto.Password)) 33 | return Unauthorized(); 34 | 35 | var userBasket = await RetrieveBasket(loginDto.Username); 36 | var anonBasket = await RetrieveBasket(Request.Cookies["buyerId"]); 37 | 38 | if (anonBasket != null) 39 | { 40 | if (userBasket != null) _context.Baskets.Remove(userBasket); 41 | anonBasket.BuyerId = user.UserName; 42 | Response.Cookies.Delete("buyerId"); 43 | await _context.SaveChangesAsync(); 44 | } 45 | 46 | return new UserDto 47 | { 48 | Email = user.Email, 49 | Token = await _tokenService.GenerateToken(user), 50 | Basket = anonBasket != null ? anonBasket.MapBasketToDto() : userBasket?.MapBasketToDto() 51 | }; 52 | } 53 | 54 | [HttpPost("register")] 55 | public async Task Register(RegisterDto registerDto) 56 | { 57 | var user = new User { UserName = registerDto.Username, Email = registerDto.Email }; 58 | 59 | var result = await _userManager.CreateAsync(user, registerDto.Password); 60 | 61 | if (!result.Succeeded) 62 | { 63 | foreach (var error in result.Errors) 64 | { 65 | ModelState.AddModelError(error.Code, error.Description); 66 | } 67 | 68 | return ValidationProblem(); 69 | } 70 | 71 | await _userManager.AddToRoleAsync(user, "Member"); 72 | 73 | return StatusCode(201); 74 | } 75 | 76 | [Authorize] 77 | [HttpGet("currentUser")] 78 | public async Task> GetCurrentUser() 79 | { 80 | var user = await _userManager.FindByNameAsync(User.Identity.Name); 81 | 82 | var userBasket = await RetrieveBasket(User.Identity.Name); 83 | 84 | return new UserDto 85 | { 86 | Email = user.Email, 87 | Token = await _tokenService.GenerateToken(user), 88 | Basket = userBasket?.MapBasketToDto() 89 | }; 90 | } 91 | 92 | [Authorize] 93 | [HttpGet("savedAddress")] 94 | public async Task> GetSavedAddress() 95 | { 96 | return await _userManager.Users 97 | .Where(x => x.UserName == User.Identity.Name) 98 | .Select(user => user.Address) 99 | .FirstOrDefaultAsync(); 100 | } 101 | 102 | private async Task RetrieveBasket(string buyerId) 103 | { 104 | if (string.IsNullOrEmpty(buyerId)) 105 | { 106 | Response.Cookies.Delete("buyerId"); 107 | return null; 108 | } 109 | 110 | return await _context.Baskets 111 | .Include(i => i.Items) 112 | .ThenInclude(p => p.Product) 113 | .FirstOrDefaultAsync(x => x.BuyerId == buyerId); 114 | } 115 | } 116 | } -------------------------------------------------------------------------------- /API/Controllers/BaseApiController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | namespace API.Controllers 4 | { 5 | [ApiController] 6 | [Route("api/[controller]")] 7 | public class BaseApiController : ControllerBase 8 | { 9 | 10 | } 11 | } -------------------------------------------------------------------------------- /API/Controllers/BasketController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using API.Data; 5 | using API.DTOs; 6 | using API.Entities; 7 | using API.Extensions; 8 | using Microsoft.AspNetCore.Http; 9 | using Microsoft.AspNetCore.Mvc; 10 | using Microsoft.EntityFrameworkCore; 11 | 12 | namespace API.Controllers 13 | { 14 | public class BasketController : BaseApiController 15 | { 16 | private readonly StoreContext _context; 17 | public BasketController(StoreContext context) 18 | { 19 | _context = context; 20 | } 21 | 22 | [HttpGet(Name = "GetBasket")] 23 | public async Task> GetBasket() 24 | { 25 | var basket = await RetrieveBasket(GetBuyerId()); 26 | 27 | if (basket == null) return NotFound(); 28 | 29 | return basket.MapBasketToDto(); 30 | } 31 | 32 | [HttpPost] 33 | public async Task> AddItemToBasket(int productId, int quantity) 34 | { 35 | var basket = await RetrieveBasket(GetBuyerId()); 36 | 37 | if (basket == null) basket = CreateBasket(); 38 | 39 | var product = await _context.Products.FindAsync(productId); 40 | 41 | if (product == null) return BadRequest(new ProblemDetails { Title = "Product not found" }); 42 | 43 | basket.AddItem(product, quantity); 44 | 45 | var result = await _context.SaveChangesAsync() > 0; 46 | 47 | if (result) return CreatedAtRoute("GetBasket", basket.MapBasketToDto()); 48 | 49 | return BadRequest(new ProblemDetails { Title = "Problem saving item to basket" }); 50 | } 51 | 52 | [HttpDelete] 53 | public async Task RemoveBasketItem(int productId, int quantity) 54 | { 55 | var basket = await RetrieveBasket(GetBuyerId()); 56 | 57 | if (basket == null) return NotFound(); 58 | 59 | basket.RemoveItem(productId, quantity); 60 | 61 | var result = await _context.SaveChangesAsync() > 0; 62 | 63 | if (result) return Ok(); 64 | 65 | return BadRequest(new ProblemDetails { Title = "Problem removing item from the basket" }); 66 | } 67 | 68 | private async Task RetrieveBasket(string buyerId) 69 | { 70 | if (string.IsNullOrEmpty(buyerId)) 71 | { 72 | Response.Cookies.Delete("buyerId"); 73 | return null; 74 | } 75 | 76 | return await _context.Baskets 77 | .Include(i => i.Items) 78 | .ThenInclude(p => p.Product) 79 | .FirstOrDefaultAsync(x => x.BuyerId == buyerId); 80 | } 81 | 82 | private Basket CreateBasket() 83 | { 84 | var buyerId = User.Identity?.Name; 85 | if (string.IsNullOrEmpty(buyerId)) 86 | { 87 | buyerId = Guid.NewGuid().ToString(); 88 | var cookieOptions = new CookieOptions { IsEssential = true, Expires = DateTime.Now.AddDays(30) }; 89 | Response.Cookies.Append("buyerId", buyerId, cookieOptions); 90 | } 91 | var basket = new Basket { BuyerId = buyerId }; 92 | _context.Baskets.Add(basket); 93 | return basket; 94 | } 95 | 96 | private string GetBuyerId() 97 | { 98 | return User.Identity?.Name ?? Request.Cookies["buyerId"]; 99 | } 100 | } 101 | } -------------------------------------------------------------------------------- /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 ActionResult GetNotFound() 10 | { 11 | return NotFound(); 12 | } 13 | 14 | [HttpGet("bad-request")] 15 | public ActionResult GetBadRequest() 16 | { 17 | return BadRequest(new ProblemDetails{Title = "This is a bad request"}); 18 | } 19 | 20 | [HttpGet("unauthorised")] 21 | public ActionResult GetUnauthorised() 22 | { 23 | return Unauthorized(); 24 | } 25 | 26 | [HttpGet("validation-error")] 27 | public ActionResult 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 ActionResult GetServerError() 36 | { 37 | throw new Exception("This is a server error"); 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /API/Controllers/FallbackController.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using Microsoft.AspNetCore.Mvc; 3 | 4 | namespace API.Controllers 5 | { 6 | public class FallbackController : Controller 7 | { 8 | public IActionResult Index() 9 | { 10 | return PhysicalFile(Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "index.html"), 11 | "text/HTML"); 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /API/Controllers/OrdersController.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using API.Data; 5 | using API.DTOs; 6 | using API.Entities; 7 | using API.Entities.OrderAggregate; 8 | using API.Extensions; 9 | using Microsoft.AspNetCore.Authorization; 10 | using Microsoft.AspNetCore.Mvc; 11 | using Microsoft.EntityFrameworkCore; 12 | 13 | namespace API.Controllers 14 | { 15 | [Authorize] 16 | public class OrdersController : BaseApiController 17 | { 18 | private readonly StoreContext _context; 19 | public OrdersController(StoreContext context) 20 | { 21 | _context = context; 22 | 23 | } 24 | 25 | [HttpGet] 26 | public async Task>> GetOrders() 27 | { 28 | return await _context.Orders 29 | .ProjectOrderToOrderDto() 30 | .Where(x => x.BuyerId == User.Identity.Name) 31 | .ToListAsync(); 32 | } 33 | 34 | [HttpGet("{id}", Name = "GetOrder")] 35 | public async Task> GetOrder(int id) 36 | { 37 | return await _context.Orders 38 | .ProjectOrderToOrderDto() 39 | .Where(x => x.BuyerId == User.Identity.Name && x.Id == id) 40 | .FirstOrDefaultAsync(); 41 | } 42 | 43 | [HttpPost] 44 | public async Task> CreateOrder(CreateOrderDto orderDto) 45 | { 46 | var basket = await _context.Baskets 47 | .RetrieveBasketWithItems(User.Identity.Name) 48 | .FirstOrDefaultAsync(); 49 | 50 | if (basket == null) return BadRequest(new ProblemDetails{Title = "Could not locate basket"}); 51 | 52 | var items = new List(); 53 | 54 | foreach (var item in basket.Items) 55 | { 56 | var productItem = await _context.Products.FindAsync(item.ProductId); 57 | var itemOrdered = new ProductItemOrdered 58 | { 59 | ProductId = productItem.Id, 60 | Name = productItem.Name, 61 | PictureUrl = productItem.PictureUrl 62 | }; 63 | 64 | var orderItem = new OrderItem 65 | { 66 | ItemOrdered = itemOrdered, 67 | Price = productItem.Price, 68 | Quantity = item.Quantity 69 | }; 70 | items.Add(orderItem); 71 | productItem.QuantityInStock -= item.Quantity; 72 | } 73 | 74 | var subtotal = items.Sum(item => item.Price * item.Quantity); 75 | var deliveryFee = subtotal > 10000 ? 0 : 500; 76 | 77 | var order = new Order 78 | { 79 | OrderItems = items, 80 | BuyerId = User.Identity.Name, 81 | ShippingAddress = orderDto.ShippingAddress, 82 | Subtotal = subtotal, 83 | DeliveryFee = deliveryFee, 84 | PaymentIntentId = basket.PaymentIntentId 85 | }; 86 | 87 | _context.Orders.Add(order); 88 | _context.Baskets.Remove(basket); 89 | 90 | if (orderDto.SaveAddress) 91 | { 92 | var user = await _context.Users 93 | .Include(a => a.Address) 94 | .FirstOrDefaultAsync(x => x.UserName == User.Identity.Name); 95 | 96 | var address = new UserAddress 97 | { 98 | FullName = orderDto.ShippingAddress.FullName, 99 | Address1 = orderDto.ShippingAddress.Address1, 100 | Address2 = orderDto.ShippingAddress.Address2, 101 | City = orderDto.ShippingAddress.City, 102 | State = orderDto.ShippingAddress.State, 103 | Zip = orderDto.ShippingAddress.Zip, 104 | Country = orderDto.ShippingAddress.Country 105 | }; 106 | user.Address = address; 107 | } 108 | 109 | var result = await _context.SaveChangesAsync() > 0; 110 | 111 | if (result) return CreatedAtRoute("GetOrder", new {id = order.Id}, order.Id); 112 | 113 | return BadRequest(new ProblemDetails{Title = "Problem creating order"}); 114 | } 115 | } 116 | } -------------------------------------------------------------------------------- /API/Controllers/PaymentsController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | using API.Data; 7 | using API.DTOs; 8 | using API.Entities.OrderAggregate; 9 | using API.Extensions; 10 | using API.Services; 11 | using Microsoft.AspNetCore.Authorization; 12 | using Microsoft.AspNetCore.Mvc; 13 | using Microsoft.EntityFrameworkCore; 14 | using Microsoft.Extensions.Configuration; 15 | using Stripe; 16 | 17 | namespace API.Controllers 18 | { 19 | public class PaymentsController : BaseApiController 20 | { 21 | private readonly PaymentService _paymentService; 22 | private readonly StoreContext _context; 23 | private readonly IConfiguration _config; 24 | public PaymentsController(PaymentService paymentService, StoreContext context, IConfiguration config) 25 | { 26 | _config = config; 27 | _context = context; 28 | _paymentService = paymentService; 29 | } 30 | 31 | [Authorize] 32 | [HttpPost] 33 | public async Task> CreateOrUpdatePaymentIntent() 34 | { 35 | var basket = await _context.Baskets 36 | .RetrieveBasketWithItems(User.Identity.Name) 37 | .FirstOrDefaultAsync(); 38 | 39 | if (basket == null) return NotFound(); 40 | 41 | var intent = await _paymentService.CreateOrUpdatePaymentIntent(basket); 42 | 43 | if (intent == null) return BadRequest(new ProblemDetails { Title = "Problem creating payment intent" }); 44 | 45 | basket.PaymentIntentId = basket.PaymentIntentId ?? intent.Id; 46 | basket.ClientSecret = basket.ClientSecret ?? intent.ClientSecret; 47 | 48 | _context.Update(basket); 49 | 50 | var result = await _context.SaveChangesAsync() > 0; 51 | 52 | if (!result) return BadRequest(new ProblemDetails { Title = "Problem updating basket with intent" }); 53 | 54 | return basket.MapBasketToDto(); 55 | } 56 | 57 | [HttpPost("webhook")] 58 | public async Task StripeWebhook() 59 | { 60 | var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync(); 61 | 62 | var stripeEvent = EventUtility.ConstructEvent(json, Request.Headers["Stripe-Signature"], 63 | _config["StripeSettings:WhSecret"]); 64 | 65 | var charge = (Charge)stripeEvent.Data.Object; 66 | 67 | var order = await _context.Orders.FirstOrDefaultAsync(x => 68 | x.PaymentIntentId == charge.PaymentIntentId); 69 | 70 | if (charge.Status == "succeeded") order.OrderStatus = OrderStatus.PaymentReceived; 71 | 72 | await _context.SaveChangesAsync(); 73 | 74 | return new EmptyResult(); 75 | } 76 | } 77 | } -------------------------------------------------------------------------------- /API/Controllers/ProductsController.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System.Text.Json; 4 | using System.Threading.Tasks; 5 | using API.Data; 6 | using API.DTOs; 7 | using API.Entities; 8 | using API.Extensions; 9 | using API.RequestHelpers; 10 | using API.Services; 11 | using AutoMapper; 12 | using Microsoft.AspNetCore.Authorization; 13 | using Microsoft.AspNetCore.Mvc; 14 | using Microsoft.EntityFrameworkCore; 15 | 16 | namespace API.Controllers 17 | { 18 | public class ProductsController : BaseApiController 19 | { 20 | private readonly StoreContext _context; 21 | private readonly IMapper _mapper; 22 | private readonly ImageService _imageService; 23 | public ProductsController(StoreContext context, IMapper mapper, ImageService imageService) 24 | { 25 | _imageService = imageService; 26 | _mapper = mapper; 27 | _context = context; 28 | } 29 | 30 | [HttpGet] 31 | public async Task>> GetProducts([FromQuery] ProductParams productParams) 32 | { 33 | var query = _context.Products 34 | .Sort(productParams.OrderBy) 35 | .Search(productParams.SearchTerm) 36 | .Filter(productParams.Brands, productParams.Types) 37 | .AsQueryable(); 38 | 39 | var products = await PagedList.ToPagedList(query, 40 | productParams.PageNumber, productParams.PageSize); 41 | 42 | Response.AddPaginationHeader(products.MetaData); 43 | 44 | return products; 45 | } 46 | 47 | [HttpGet("{id}", Name = "GetProduct")] 48 | public async Task> GetProduct(int id) 49 | { 50 | var product = await _context.Products.FindAsync(id); 51 | 52 | if (product == null) return NotFound(); 53 | 54 | return product; 55 | } 56 | 57 | [HttpGet("filters")] 58 | public async Task GetFilters() 59 | { 60 | var brands = await _context.Products.Select(p => p.Brand).Distinct().ToListAsync(); 61 | var types = await _context.Products.Select(p => p.Type).Distinct().ToListAsync(); 62 | 63 | return Ok(new { brands, types }); 64 | } 65 | 66 | [Authorize(Roles = "Admin")] 67 | [HttpPost] 68 | public async Task> CreateProduct([FromForm]CreateProductDto productDto) 69 | { 70 | var product = _mapper.Map(productDto); 71 | 72 | if (productDto.File != null) 73 | { 74 | var imageResult = await _imageService.AddImageAsync(productDto.File); 75 | 76 | if (imageResult.Error != null) 77 | return BadRequest(new ProblemDetails{Title = imageResult.Error.Message}); 78 | 79 | product.PictureUrl = imageResult.SecureUrl.ToString(); 80 | product.PublicId = imageResult.PublicId; 81 | } 82 | 83 | _context.Products.Add(product); 84 | 85 | var result = await _context.SaveChangesAsync() > 0; 86 | 87 | if (result) return CreatedAtRoute("GetProduct", new { Id = product.Id }, product); 88 | 89 | return BadRequest(new ProblemDetails { Title = "Problem creating new product" }); 90 | } 91 | 92 | [Authorize(Roles = "Admin")] 93 | [HttpPut] 94 | public async Task> UpdateProduct([FromForm]UpdateProductDto productDto) 95 | { 96 | var product = await _context.Products.FindAsync(productDto.Id); 97 | 98 | if (product == null) return NotFound(); 99 | 100 | _mapper.Map(productDto, product); 101 | 102 | if (productDto.File != null) 103 | { 104 | var imageResult = await _imageService.AddImageAsync(productDto.File); 105 | 106 | if (imageResult.Error != null) 107 | return BadRequest(new ProblemDetails{Title = imageResult.Error.Message}); 108 | 109 | if (!string.IsNullOrEmpty(product.PublicId)) 110 | await _imageService.DeleteImageAsync(product.PublicId); 111 | 112 | product.PictureUrl = imageResult.SecureUrl.ToString(); 113 | product.PublicId = imageResult.PublicId; 114 | } 115 | 116 | var result = await _context.SaveChangesAsync() > 0; 117 | 118 | if (result) return Ok(product); 119 | 120 | return BadRequest(new ProblemDetails { Title = "Problem updating product" }); 121 | } 122 | 123 | [Authorize(Roles = "Admin")] 124 | [HttpDelete("{id}")] 125 | public async Task DeleteProduct(int id) 126 | { 127 | var product = await _context.Products.FindAsync(id); 128 | 129 | if (product == null) return NotFound(); 130 | 131 | if (!string.IsNullOrEmpty(product.PublicId)) 132 | await _imageService.DeleteImageAsync(product.PublicId); 133 | 134 | _context.Products.Remove(product); 135 | 136 | var result = await _context.SaveChangesAsync() > 0; 137 | 138 | if (result) return Ok(); 139 | 140 | return BadRequest(new ProblemDetails { Title = "Problem deleting product" }); 141 | } 142 | } 143 | } -------------------------------------------------------------------------------- /API/Controllers/WeatherForecastController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Microsoft.AspNetCore.Mvc; 6 | using Microsoft.Extensions.Logging; 7 | 8 | namespace API.Controllers 9 | { 10 | [ApiController] 11 | [Route("[controller]")] 12 | public class WeatherForecastController : ControllerBase 13 | { 14 | private static readonly string[] Summaries = new[] 15 | { 16 | "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" 17 | }; 18 | 19 | private readonly ILogger _logger; 20 | 21 | public WeatherForecastController(ILogger logger) 22 | { 23 | _logger = logger; 24 | } 25 | 26 | [HttpGet] 27 | public IEnumerable Get() 28 | { 29 | var rng = new Random(); 30 | return Enumerable.Range(1, 5).Select(index => new WeatherForecast 31 | { 32 | Date = DateTime.Now.AddDays(index), 33 | TemperatureC = rng.Next(-20, 55), 34 | Summary = Summaries[rng.Next(Summaries.Length)] 35 | }) 36 | .ToArray(); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /API/DTOs/BasketDto.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace API.DTOs 4 | { 5 | public class BasketDto 6 | { 7 | public int Id { get; set; } 8 | public string BuyerId { get; set; } 9 | public List Items { get; set; } 10 | public string PaymentIntentId { get; set; } 11 | public string ClientSecret { 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 string Name { get; set; } 7 | public long Price { get; set; } 8 | public string PictureUrl { get; set; } 9 | public string Brand { get; set; } 10 | public string Type { get; set; } 11 | public int Quantity { get; set; } 12 | } 13 | } -------------------------------------------------------------------------------- /API/DTOs/CreateOrderDto.cs: -------------------------------------------------------------------------------- 1 | using API.Entities.OrderAggregate; 2 | 3 | namespace API.DTOs 4 | { 5 | public class CreateOrderDto 6 | { 7 | public bool SaveAddress { get; set; } 8 | public ShippingAddress ShippingAddress { get; set; } 9 | } 10 | } -------------------------------------------------------------------------------- /API/DTOs/CreateProductDto.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.ComponentModel.DataAnnotations; 3 | using Microsoft.AspNetCore.Http; 4 | 5 | namespace API.DTOs 6 | { 7 | public class CreateProductDto 8 | { 9 | [Required] 10 | public string Name { get; set; } 11 | 12 | [Required] 13 | public string Description { get; set; } 14 | 15 | [Required] 16 | [Range(100, Double.PositiveInfinity)] 17 | public long Price { get; set; } 18 | 19 | [Required] 20 | public IFormFile File { get; set; } 21 | 22 | [Required] 23 | public string Type { get; set; } 24 | 25 | [Required] 26 | public string Brand { get; set; } 27 | 28 | [Required] 29 | [Range(0, 200)] 30 | public int QuantityInStock { get; set; } 31 | } 32 | } -------------------------------------------------------------------------------- /API/DTOs/LoginDto.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | 6 | namespace API.DTOs 7 | { 8 | public class LoginDto 9 | { 10 | public string Username { get; set; } 11 | public string Password { get; set; } 12 | } 13 | } -------------------------------------------------------------------------------- /API/DTOs/OrderDto.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using API.Entities.OrderAggregate; 4 | 5 | namespace API.DTOs 6 | { 7 | public class OrderDto 8 | { 9 | public int Id { get; set; } 10 | public string BuyerId { get; set; } 11 | public ShippingAddress ShippingAddress { get; set; } 12 | public DateTime OrderDate { get; set; } 13 | public List OrderItems { get; set; } 14 | public long Subtotal { get; set; } 15 | public long DeliveryFee { get; set; } 16 | public string OrderStatus { get; set; } 17 | public long Total { get; set; } 18 | } 19 | } -------------------------------------------------------------------------------- /API/DTOs/OrderItemDto.cs: -------------------------------------------------------------------------------- 1 | namespace API.DTOs 2 | { 3 | public class OrderItemDto 4 | { 5 | public int ProductId { get; set; } 6 | public string Name { get; set; } 7 | public string PictureUrl { get; set; } 8 | public long Price { get; set; } 9 | public int Quantity { get; set; } 10 | } 11 | } -------------------------------------------------------------------------------- /API/DTOs/RegisterDto.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | 6 | namespace API.DTOs 7 | { 8 | public class RegisterDto : LoginDto 9 | { 10 | public string Email { get; set; } 11 | } 12 | } -------------------------------------------------------------------------------- /API/DTOs/UpdateProductDto.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.ComponentModel.DataAnnotations; 3 | using Microsoft.AspNetCore.Http; 4 | 5 | namespace API.DTOs 6 | { 7 | public class UpdateProductDto 8 | { 9 | public int Id { get; set; } 10 | [Required] 11 | public string Name { get; set; } 12 | 13 | [Required] 14 | public string Description { get; set; } 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 string Type { get; set; } 24 | 25 | [Required] 26 | public string Brand { get; set; } 27 | 28 | [Required] 29 | [Range(0, 200)] 30 | public int QuantityInStock { get; set; } 31 | } 32 | } -------------------------------------------------------------------------------- /API/DTOs/UserDto.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | 6 | namespace API.DTOs 7 | { 8 | public class UserDto 9 | { 10 | public string Email { get; set; } 11 | public string Token { get; set; } 12 | public BasketDto Basket { get; set; } 13 | } 14 | } -------------------------------------------------------------------------------- /API/Data/Migrations/20211005031543_PublicIdAdded.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | namespace API.Data.Migrations 4 | { 5 | public partial class PublicIdAdded : Migration 6 | { 7 | protected override void Up(MigrationBuilder migrationBuilder) 8 | { 9 | migrationBuilder.AddColumn( 10 | name: "PublicId", 11 | table: "Products", 12 | type: "text", 13 | nullable: true); 14 | 15 | migrationBuilder.UpdateData( 16 | table: "AspNetRoles", 17 | keyColumn: "Id", 18 | keyValue: 1, 19 | column: "ConcurrencyStamp", 20 | value: "b30b7df2-b1ae-45af-8788-23f582f17bfd"); 21 | 22 | migrationBuilder.UpdateData( 23 | table: "AspNetRoles", 24 | keyColumn: "Id", 25 | keyValue: 2, 26 | column: "ConcurrencyStamp", 27 | value: "88f79dd6-c637-460f-9189-1d20af15a623"); 28 | } 29 | 30 | protected override void Down(MigrationBuilder migrationBuilder) 31 | { 32 | migrationBuilder.DropColumn( 33 | name: "PublicId", 34 | table: "Products"); 35 | 36 | migrationBuilder.UpdateData( 37 | table: "AspNetRoles", 38 | keyColumn: "Id", 39 | keyValue: 1, 40 | column: "ConcurrencyStamp", 41 | value: "a6837ef4-11b5-42be-b943-3cc00e8143d3"); 42 | 43 | migrationBuilder.UpdateData( 44 | table: "AspNetRoles", 45 | keyColumn: "Id", 46 | keyValue: 2, 47 | column: "ConcurrencyStamp", 48 | value: "27733603-b1a8-4102-af55-6a77a39262a5"); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /API/Data/StoreContext.cs: -------------------------------------------------------------------------------- 1 | using API.Entities; 2 | using API.Entities.OrderAggregate; 3 | using Microsoft.AspNetCore.Identity; 4 | using Microsoft.AspNetCore.Identity.EntityFrameworkCore; 5 | using Microsoft.EntityFrameworkCore; 6 | 7 | namespace API.Data 8 | { 9 | public class StoreContext : IdentityDbContext 10 | { 11 | public StoreContext(DbContextOptions options) : base(options) 12 | { 13 | } 14 | 15 | public DbSet Products { get; set; } 16 | public DbSet Baskets { get; set; } 17 | public DbSet Orders { get; set; } 18 | 19 | protected override void OnModelCreating(ModelBuilder builder) 20 | { 21 | base.OnModelCreating(builder); 22 | 23 | builder.Entity() 24 | .HasOne(a => a.Address) 25 | .WithOne() 26 | .HasForeignKey(a => a.Id) 27 | .OnDelete(DeleteBehavior.Cascade); 28 | 29 | builder.Entity() 30 | .HasData( 31 | new Role { Id = 1, Name = "Member", NormalizedName = "MEMBER" }, 32 | new Role { Id = 2, Name = "Admin", NormalizedName = "ADMIN" } 33 | ); 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /API/Entities/Address.cs: -------------------------------------------------------------------------------- 1 | namespace API.Entities 2 | { 3 | public class Address 4 | { 5 | public string FullName { get; set; } 6 | public string Address1 { get; set; } 7 | public string Address2 { get; set; } 8 | public string City { get; set; } 9 | public string State { get; set; } 10 | public string Zip { get; set; } 11 | public string Country { get; set; } 12 | } 13 | } -------------------------------------------------------------------------------- /API/Entities/Basket.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | 4 | namespace API.Entities 5 | { 6 | public class Basket 7 | { 8 | public int Id { get; set; } 9 | public string BuyerId { get; set; } 10 | public List Items { get; set; } = new(); 11 | public string PaymentIntentId { get; set; } 12 | public string ClientSecret { get; set; } 13 | 14 | public void AddItem(Product product, int quantity) 15 | { 16 | if (Items.All(item => item.ProductId != product.Id)) 17 | { 18 | Items.Add(new BasketItem{Product = product, Quantity = quantity}); 19 | } 20 | 21 | var existingItem = Items.FirstOrDefault(item => item.ProductId == product.Id); 22 | if (existingItem != null) existingItem.Quantity += quantity; 23 | } 24 | 25 | public void RemoveItem(int productId, int quantity) 26 | { 27 | var item = Items.FirstOrDefault(item => item.ProductId == productId); 28 | if (item == null) return; 29 | item.Quantity -= quantity; 30 | if (item.Quantity == 0) Items.Remove(item); 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /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 Product Product { get; set; } 14 | 15 | public int BasketId { get; set; } 16 | public Basket Basket { get; set; } 17 | } 18 | } -------------------------------------------------------------------------------- /API/Entities/OrderAggregate/Order.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace API.Entities.OrderAggregate 5 | { 6 | public class Order 7 | { 8 | public int Id { get; set; } 9 | public string BuyerId { get; set; } 10 | public ShippingAddress ShippingAddress { get; set; } 11 | public DateTime OrderDate { get; set; } = DateTime.Now; 12 | public List OrderItems { get; set; } 13 | public long Subtotal { get; set; } 14 | public long DeliveryFee { get; set; } 15 | public OrderStatus OrderStatus { get; set; } = OrderStatus.Pending; 16 | public string PaymentIntentId { get; set; } 17 | 18 | public long GetTotal() 19 | { 20 | return Subtotal + DeliveryFee; 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /API/Entities/OrderAggregate/OrderItem.cs: -------------------------------------------------------------------------------- 1 | namespace API.Entities.OrderAggregate 2 | { 3 | public class OrderItem 4 | { 5 | public int Id { get; set; } 6 | public ProductItemOrdered ItemOrdered { get; set; } 7 | public long Price { get; set; } 8 | public int Quantity { get; set; } 9 | } 10 | } -------------------------------------------------------------------------------- /API/Entities/OrderAggregate/OrderStatus.cs: -------------------------------------------------------------------------------- 1 | namespace API.Entities.OrderAggregate 2 | { 3 | public enum OrderStatus 4 | { 5 | Pending, 6 | PaymentReceived, 7 | PaymentFailed 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /API/Entities/OrderAggregate/ProductItemOrdered.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | 3 | namespace API.Entities.OrderAggregate 4 | { 5 | [Owned] 6 | public class ProductItemOrdered 7 | { 8 | public int ProductId { get; set; } 9 | public string Name { get; set; } 10 | public string PictureUrl { get; set; } 11 | } 12 | } -------------------------------------------------------------------------------- /API/Entities/OrderAggregate/ShippingAddress.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | 3 | namespace API.Entities.OrderAggregate 4 | { 5 | [Owned] 6 | public class ShippingAddress : Address 7 | { 8 | 9 | } 10 | } -------------------------------------------------------------------------------- /API/Entities/Product.cs: -------------------------------------------------------------------------------- 1 | namespace API.Entities 2 | { 3 | public class Product 4 | { 5 | public int Id { get; set; } 6 | public string Name { get; set; } 7 | public string Description { get; set; } 8 | public long Price { get; set; } 9 | public string PictureUrl { get; set; } 10 | public string Type { get; set; } 11 | public string Brand { get; set; } 12 | public int QuantityInStock { get; set; } 13 | public string PublicId { get; set; } 14 | } 15 | } -------------------------------------------------------------------------------- /API/Entities/Role.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Identity; 2 | 3 | namespace API.Entities 4 | { 5 | public class Role : IdentityRole 6 | { 7 | 8 | } 9 | } -------------------------------------------------------------------------------- /API/Entities/User.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Identity; 2 | 3 | namespace API.Entities 4 | { 5 | public class User : IdentityUser 6 | { 7 | public UserAddress Address { get; set; } 8 | } 9 | } -------------------------------------------------------------------------------- /API/Entities/UserAddress.cs: -------------------------------------------------------------------------------- 1 | namespace API.Entities 2 | { 3 | public class UserAddress : Address 4 | { 5 | public int Id { get; set; } 6 | } 7 | } -------------------------------------------------------------------------------- /API/Extensions/BasketExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 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 MapBasketToDto(this Basket basket) 11 | { 12 | return new BasketDto 13 | { 14 | Id = basket.Id, 15 | BuyerId = basket.BuyerId, 16 | PaymentIntentId = basket.PaymentIntentId, 17 | ClientSecret = basket.ClientSecret, 18 | Items = basket.Items.Select(item => new BasketItemDto 19 | { 20 | ProductId = item.ProductId, 21 | Name = item.Product.Name, 22 | Price = item.Product.Price, 23 | PictureUrl = item.Product.PictureUrl, 24 | Type = item.Product.Type, 25 | Brand = item.Product.Brand, 26 | Quantity = item.Quantity 27 | }).ToList() 28 | }; 29 | } 30 | 31 | public static IQueryable RetrieveBasketWithItems(this IQueryable query, string buyerId) 32 | { 33 | return query.Include(i => i.Items).ThenInclude(p => p.Product).Where(b => b.BuyerId == buyerId); 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /API/Extensions/HttpExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using API.RequestHelpers; 3 | using Microsoft.AspNetCore.Http; 4 | 5 | namespace API.Extensions 6 | { 7 | public static class HttpExtensions 8 | { 9 | public static void AddPaginationHeader(this HttpResponse response, MetaData metaData) 10 | { 11 | var options = new JsonSerializerOptions{PropertyNamingPolicy = JsonNamingPolicy.CamelCase}; 12 | 13 | response.Headers.Add("Pagination", JsonSerializer.Serialize(metaData, options)); 14 | response.Headers.Add("Access-Control-Expose-Headers", "Pagination"); 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /API/Extensions/OrderExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using API.DTOs; 6 | using API.Entities.OrderAggregate; 7 | using Microsoft.EntityFrameworkCore; 8 | 9 | namespace API.Extensions 10 | { 11 | public static class OrderExtensions 12 | { 13 | public static IQueryable ProjectOrderToOrderDto(this IQueryable query) 14 | { 15 | return query 16 | .Select(order => new OrderDto 17 | { 18 | Id = order.Id, 19 | BuyerId = order.BuyerId, 20 | OrderDate = order.OrderDate, 21 | ShippingAddress = order.ShippingAddress, 22 | DeliveryFee = order.DeliveryFee, 23 | Subtotal = order.Subtotal, 24 | OrderStatus = order.OrderStatus.ToString(), 25 | Total = order.GetTotal(), 26 | OrderItems = order.OrderItems.Select(item => new OrderItemDto 27 | { 28 | ProductId = item.ItemOrdered.ProductId, 29 | Name = item.ItemOrdered.Name, 30 | PictureUrl = item.ItemOrdered.PictureUrl, 31 | Price = item.Price, 32 | Quantity = item.Quantity 33 | }).ToList() 34 | }).AsNoTracking(); 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /API/Extensions/ProductExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using API.Entities; 4 | 5 | namespace API.Extensions 6 | { 7 | public static class ProductExtensions 8 | { 9 | public static IQueryable Sort(this IQueryable query, string orderBy) 10 | { 11 | if (string.IsNullOrEmpty(orderBy)) return query.OrderBy(p => p.Name); 12 | 13 | query = orderBy switch 14 | { 15 | "price" => query.OrderBy(p => p.Price), 16 | "priceDesc" => query.OrderByDescending(p => p.Price), 17 | _ => query.OrderBy(p => p.Name) 18 | }; 19 | 20 | return query; 21 | } 22 | 23 | public static IQueryable Search(this IQueryable query, string searchTerm) 24 | { 25 | if (string.IsNullOrEmpty(searchTerm)) return query; 26 | 27 | var lowerCaseSearchTerm = searchTerm.Trim().ToLower(); 28 | 29 | return query.Where(p => p.Name.ToLower().Contains(lowerCaseSearchTerm)); 30 | } 31 | 32 | public static IQueryable Filter(this IQueryable query, string brands, string types) 33 | { 34 | var brandList = new List(); 35 | var typeList = new List(); 36 | 37 | if (!string.IsNullOrEmpty(brands)) 38 | brandList.AddRange(brands.ToLower().Split(",").ToList()); 39 | 40 | if (!string.IsNullOrEmpty(types)) 41 | typeList.AddRange(types.ToLower().Split(",").ToList()); 42 | 43 | query = query.Where(p => brandList.Count == 0 || brandList.Contains(p.Brand.ToLower())); 44 | query = query.Where(p => typeList.Count == 0 || typeList.Contains(p.Type.ToLower())); 45 | 46 | return query; 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /API/Middleware/ExceptionMiddleware.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Json; 3 | using System.Threading.Tasks; 4 | using Microsoft.AspNetCore.Http; 5 | using Microsoft.AspNetCore.Mvc; 6 | using Microsoft.Extensions.Hosting; 7 | using Microsoft.Extensions.Logging; 8 | 9 | namespace API.Middleware 10 | { 11 | public class ExceptionMiddleware 12 | { 13 | private readonly RequestDelegate _next; 14 | private readonly ILogger _logger; 15 | private readonly IHostEnvironment _env; 16 | public ExceptionMiddleware(RequestDelegate next, ILogger logger, 17 | IHostEnvironment env) 18 | { 19 | _env = env; 20 | _logger = logger; 21 | _next = next; 22 | } 23 | 24 | public async Task InvokeAsync(HttpContext context) 25 | { 26 | try 27 | { 28 | await _next(context); 29 | } 30 | catch (Exception ex) 31 | { 32 | _logger.LogError(ex, ex.Message); 33 | context.Response.ContentType = "application/json"; 34 | context.Response.StatusCode = 500; 35 | 36 | var response = new ProblemDetails 37 | { 38 | Status = 500, 39 | Detail = _env.IsDevelopment() ? ex.StackTrace?.ToString() : null, 40 | Title = ex.Message 41 | }; 42 | 43 | var options = new JsonSerializerOptions{PropertyNamingPolicy = 44 | JsonNamingPolicy.CamelCase}; 45 | 46 | var json = JsonSerializer.Serialize(response, options); 47 | 48 | await context.Response.WriteAsync(json); 49 | } 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /API/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using API.Data; 4 | using API.Entities; 5 | using Microsoft.AspNetCore.Hosting; 6 | using Microsoft.AspNetCore.Identity; 7 | using Microsoft.EntityFrameworkCore; 8 | using Microsoft.Extensions.DependencyInjection; 9 | using Microsoft.Extensions.Hosting; 10 | using Microsoft.Extensions.Logging; 11 | 12 | namespace API 13 | { 14 | public class Program 15 | { 16 | public static async Task Main(string[] args) 17 | { 18 | var host = CreateHostBuilder(args).Build(); 19 | using var scope = host.Services.CreateScope(); 20 | var context = scope.ServiceProvider.GetRequiredService(); 21 | var userManager = scope.ServiceProvider.GetRequiredService>(); 22 | var logger = scope.ServiceProvider.GetRequiredService>(); 23 | try 24 | { 25 | await context.Database.MigrateAsync(); 26 | await DbInitializer.Initialize(context, userManager); 27 | } 28 | catch (Exception ex) 29 | { 30 | logger.LogError(ex, "Problem migrating data"); 31 | } 32 | 33 | await host.RunAsync(); 34 | } 35 | 36 | public static IHostBuilder CreateHostBuilder(string[] args) => 37 | Host.CreateDefaultBuilder(args) 38 | .ConfigureWebHostDefaults(webBuilder => 39 | { 40 | webBuilder.UseStartup(); 41 | }); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /API/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "iisSettings": { 4 | "windowsAuthentication": false, 5 | "anonymousAuthentication": true, 6 | "iisExpress": { 7 | "applicationUrl": "http://localhost:63057", 8 | "sslPort": 44391 9 | } 10 | }, 11 | "profiles": { 12 | "IIS Express": { 13 | "commandName": "IISExpress", 14 | "launchBrowser": true, 15 | "launchUrl": "swagger", 16 | "environmentVariables": { 17 | "ASPNETCORE_ENVIRONMENT": "Development" 18 | } 19 | }, 20 | "API": { 21 | "commandName": "Project", 22 | "dotnetRunMessages": "true", 23 | "launchBrowser": false, 24 | "launchUrl": "swagger", 25 | "applicationUrl": "http://localhost:5000", 26 | "environmentVariables": { 27 | "ASPNETCORE_ENVIRONMENT": "Development" 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /API/RequestHelpers/MappingProfiles.cs: -------------------------------------------------------------------------------- 1 | using API.DTOs; 2 | using API.Entities; 3 | using AutoMapper; 4 | 5 | namespace API.RequestHelpers 6 | { 7 | public class MappingProfiles : Profile 8 | { 9 | public MappingProfiles() 10 | { 11 | CreateMap(); 12 | CreateMap(); 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /API/RequestHelpers/MetaData.cs: -------------------------------------------------------------------------------- 1 | namespace API.RequestHelpers 2 | { 3 | public class MetaData 4 | { 5 | public int CurrentPage { get; set; } 6 | public int TotalPages { get; set; } 7 | public int PageSize { get; set; } 8 | public int TotalCount { get; set; } 9 | } 10 | } -------------------------------------------------------------------------------- /API/RequestHelpers/PagedList.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Microsoft.EntityFrameworkCore; 6 | 7 | namespace API.RequestHelpers 8 | { 9 | public class PagedList : List 10 | { 11 | public PagedList(List items, int count, int pageNumber, int pageSize) 12 | { 13 | MetaData = new MetaData 14 | { 15 | TotalCount = count, 16 | PageSize = pageSize, 17 | CurrentPage = pageNumber, 18 | TotalPages = (int)Math.Ceiling(count / (double)pageSize) 19 | }; 20 | AddRange(items); 21 | } 22 | 23 | public MetaData MetaData { get; set; } 24 | 25 | public static async Task> ToPagedList(IQueryable query, 26 | int pageNumber, int pageSize) 27 | { 28 | var count = await query.CountAsync(); 29 | var items = await query.Skip((pageNumber-1)*pageSize).Take(pageSize).ToListAsync(); 30 | return new PagedList(items, count, pageNumber, pageSize); 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /API/RequestHelpers/PaginationParams.cs: -------------------------------------------------------------------------------- 1 | namespace API.RequestHelpers 2 | { 3 | public class PaginationParams 4 | { 5 | private const int MaxPageSize = 50; 6 | public int PageNumber { get; set; } = 1; 7 | private int _pageSize = 6; 8 | public int PageSize 9 | { 10 | get => _pageSize; 11 | set => _pageSize = value > MaxPageSize ? MaxPageSize : value; 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /API/RequestHelpers/ProductParams.cs: -------------------------------------------------------------------------------- 1 | namespace API.RequestHelpers 2 | { 3 | public class ProductParams : PaginationParams 4 | { 5 | public string OrderBy { get; set; } 6 | public string SearchTerm { get; set; } 7 | public string Types { get; set; } 8 | public string Brands { get; set; } 9 | } 10 | } -------------------------------------------------------------------------------- /API/Services/ImageService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using CloudinaryDotNet; 6 | using CloudinaryDotNet.Actions; 7 | using Microsoft.AspNetCore.Http; 8 | using Microsoft.Extensions.Configuration; 9 | 10 | namespace API.Services 11 | { 12 | public class ImageService 13 | { 14 | private readonly Cloudinary _cloudinary; 15 | public ImageService(IConfiguration config) 16 | { 17 | var acc = new Account 18 | ( 19 | config["Cloudinary:CloudName"], 20 | config["Cloudinary:ApiKey"], 21 | config["Cloudinary:ApiSecret"] 22 | ); 23 | 24 | _cloudinary = new Cloudinary(acc); 25 | } 26 | 27 | public async Task AddImageAsync(IFormFile file) 28 | { 29 | var uploadResult = new ImageUploadResult(); 30 | 31 | if (file.Length > 0) 32 | { 33 | using var stream = file.OpenReadStream(); 34 | var uploadParams = new ImageUploadParams 35 | { 36 | File = new FileDescription(file.FileName, stream) 37 | }; 38 | uploadResult = await _cloudinary.UploadAsync(uploadParams); 39 | } 40 | 41 | return uploadResult; 42 | } 43 | 44 | public async Task DeleteImageAsync(string publicId) 45 | { 46 | var deleteParams = new DeletionParams(publicId); 47 | 48 | var result = await _cloudinary.DestroyAsync(deleteParams); 49 | 50 | return result; 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /API/Services/PaymentService.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using API.Entities; 5 | using Microsoft.Extensions.Configuration; 6 | using Stripe; 7 | 8 | namespace API.Services 9 | { 10 | public class PaymentService 11 | { 12 | private readonly IConfiguration _config; 13 | public PaymentService(IConfiguration config) 14 | { 15 | _config = config; 16 | } 17 | 18 | public async Task CreateOrUpdatePaymentIntent(Basket basket) 19 | { 20 | StripeConfiguration.ApiKey = _config["StripeSettings:SecretKey"]; 21 | 22 | var service = new PaymentIntentService(); 23 | 24 | var intent = new PaymentIntent(); 25 | 26 | var subtotal = basket.Items.Sum(item => item.Quantity * item.Product.Price); 27 | var deliveryFee = subtotal > 10000 ? 0 : 500; 28 | 29 | if (string.IsNullOrEmpty(basket.PaymentIntentId)) 30 | { 31 | var options = new PaymentIntentCreateOptions 32 | { 33 | Amount = subtotal + deliveryFee, 34 | Currency = "usd", 35 | PaymentMethodTypes = new List {"card"} 36 | }; 37 | intent = await service.CreateAsync(options); 38 | } 39 | else 40 | { 41 | var options = new PaymentIntentUpdateOptions 42 | { 43 | Amount = subtotal + deliveryFee 44 | }; 45 | await service.UpdateAsync(basket.PaymentIntentId, options); 46 | } 47 | 48 | return intent; 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /API/Services/TokenService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IdentityModel.Tokens.Jwt; 4 | using System.Security.Claims; 5 | using System.Text; 6 | using System.Threading.Tasks; 7 | using API.Entities; 8 | using Microsoft.AspNetCore.Identity; 9 | using Microsoft.Extensions.Configuration; 10 | using Microsoft.IdentityModel.Tokens; 11 | 12 | namespace API.Services 13 | { 14 | public class TokenService 15 | { 16 | private readonly UserManager _userManager; 17 | private readonly IConfiguration _config; 18 | public TokenService(UserManager userManager, IConfiguration config) 19 | { 20 | _config = config; 21 | _userManager = userManager; 22 | } 23 | 24 | public async Task GenerateToken(User user) 25 | { 26 | var claims = new List 27 | { 28 | new Claim(ClaimTypes.Email, user.Email), 29 | new Claim(ClaimTypes.Name, user.UserName) 30 | }; 31 | 32 | var roles = await _userManager.GetRolesAsync(user); 33 | 34 | foreach (var role in roles) 35 | { 36 | claims.Add(new Claim(ClaimTypes.Role, role)); 37 | } 38 | 39 | var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["JWTSettings:TokenKey"])); 40 | var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha512); 41 | 42 | var tokenOptions = new JwtSecurityToken 43 | ( 44 | issuer: null, 45 | audience: null, 46 | claims: claims, 47 | expires: DateTime.Now.AddDays(7), 48 | signingCredentials: creds 49 | ); 50 | 51 | return new JwtSecurityTokenHandler().WriteToken(tokenOptions); 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /API/WeatherForecast.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace API 4 | { 5 | public class WeatherForecast 6 | { 7 | public DateTime Date { get; set; } 8 | 9 | public int TemperatureC { get; set; } 10 | 11 | public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); 12 | 13 | public string Summary { get; set; } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /API/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Information", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | }, 9 | "ConnectionStrings": { 10 | "DefaultConnection": "Server=localhost;Port=5432;User Id=appuser;Password=secret;Database=store" 11 | }, 12 | "JWTSettings": { 13 | "TokenKey": "this is a secret key and needs to be at least 12 characters" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /API/store.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/API/store.db -------------------------------------------------------------------------------- /API/wwwroot/asset-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": { 3 | "main.css": "/static/css/main.afd7172b.chunk.css", 4 | "main.js": "/static/js/main.885616a9.chunk.js", 5 | "main.js.map": "/static/js/main.885616a9.chunk.js.map", 6 | "runtime-main.js": "/static/js/runtime-main.14d8c866.js", 7 | "runtime-main.js.map": "/static/js/runtime-main.14d8c866.js.map", 8 | "static/css/2.31549f77.chunk.css": "/static/css/2.31549f77.chunk.css", 9 | "static/js/2.8d234b4e.chunk.js": "/static/js/2.8d234b4e.chunk.js", 10 | "static/js/2.8d234b4e.chunk.js.map": "/static/js/2.8d234b4e.chunk.js.map", 11 | "static/js/3.831e55aa.chunk.js": "/static/js/3.831e55aa.chunk.js", 12 | "static/js/3.831e55aa.chunk.js.map": "/static/js/3.831e55aa.chunk.js.map", 13 | "index.html": "/index.html", 14 | "static/css/2.31549f77.chunk.css.map": "/static/css/2.31549f77.chunk.css.map", 15 | "static/css/main.afd7172b.chunk.css.map": "/static/css/main.afd7172b.chunk.css.map", 16 | "static/js/2.8d234b4e.chunk.js.LICENSE.txt": "/static/js/2.8d234b4e.chunk.js.LICENSE.txt", 17 | "static/media/slick-theme.css": "/static/media/slick.c94f7671.ttf" 18 | }, 19 | "entrypoints": [ 20 | "static/js/runtime-main.14d8c866.js", 21 | "static/css/2.31549f77.chunk.css", 22 | "static/js/2.8d234b4e.chunk.js", 23 | "static/css/main.afd7172b.chunk.css", 24 | "static/js/main.885616a9.chunk.js" 25 | ] 26 | } -------------------------------------------------------------------------------- /API/wwwroot/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/API/wwwroot/favicon.ico -------------------------------------------------------------------------------- /API/wwwroot/images/hero1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/API/wwwroot/images/hero1.jpg -------------------------------------------------------------------------------- /API/wwwroot/images/hero2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/API/wwwroot/images/hero2.jpg -------------------------------------------------------------------------------- /API/wwwroot/images/hero3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/API/wwwroot/images/hero3.jpg -------------------------------------------------------------------------------- /API/wwwroot/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/API/wwwroot/images/logo.png -------------------------------------------------------------------------------- /API/wwwroot/images/placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/API/wwwroot/images/placeholder.png -------------------------------------------------------------------------------- /API/wwwroot/images/products/boot-ang1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/API/wwwroot/images/products/boot-ang1.png -------------------------------------------------------------------------------- /API/wwwroot/images/products/boot-ang2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/API/wwwroot/images/products/boot-ang2.png -------------------------------------------------------------------------------- /API/wwwroot/images/products/boot-core1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/API/wwwroot/images/products/boot-core1.png -------------------------------------------------------------------------------- /API/wwwroot/images/products/boot-core2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/API/wwwroot/images/products/boot-core2.png -------------------------------------------------------------------------------- /API/wwwroot/images/products/boot-redis1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/API/wwwroot/images/products/boot-redis1.png -------------------------------------------------------------------------------- /API/wwwroot/images/products/glove-code1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/API/wwwroot/images/products/glove-code1.png -------------------------------------------------------------------------------- /API/wwwroot/images/products/glove-code2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/API/wwwroot/images/products/glove-code2.png -------------------------------------------------------------------------------- /API/wwwroot/images/products/glove-react1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/API/wwwroot/images/products/glove-react1.png -------------------------------------------------------------------------------- /API/wwwroot/images/products/glove-react2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/API/wwwroot/images/products/glove-react2.png -------------------------------------------------------------------------------- /API/wwwroot/images/products/hat-core1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/API/wwwroot/images/products/hat-core1.png -------------------------------------------------------------------------------- /API/wwwroot/images/products/hat-react1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/API/wwwroot/images/products/hat-react1.png -------------------------------------------------------------------------------- /API/wwwroot/images/products/hat-react2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/API/wwwroot/images/products/hat-react2.png -------------------------------------------------------------------------------- /API/wwwroot/images/products/sb-ang1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/API/wwwroot/images/products/sb-ang1.png -------------------------------------------------------------------------------- /API/wwwroot/images/products/sb-ang2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/API/wwwroot/images/products/sb-ang2.png -------------------------------------------------------------------------------- /API/wwwroot/images/products/sb-core1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/API/wwwroot/images/products/sb-core1.png -------------------------------------------------------------------------------- /API/wwwroot/images/products/sb-core2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/API/wwwroot/images/products/sb-core2.png -------------------------------------------------------------------------------- /API/wwwroot/images/products/sb-react1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/API/wwwroot/images/products/sb-react1.png -------------------------------------------------------------------------------- /API/wwwroot/images/products/sb-ts1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/API/wwwroot/images/products/sb-ts1.png -------------------------------------------------------------------------------- /API/wwwroot/index.html: -------------------------------------------------------------------------------- 1 | ReStore
-------------------------------------------------------------------------------- /API/wwwroot/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/API/wwwroot/logo192.png -------------------------------------------------------------------------------- /API/wwwroot/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/API/wwwroot/logo512.png -------------------------------------------------------------------------------- /API/wwwroot/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /API/wwwroot/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /API/wwwroot/static/css/main.afd7172b.chunk.css: -------------------------------------------------------------------------------- 1 | 2 | /*# sourceMappingURL=main.afd7172b.chunk.css.map */ -------------------------------------------------------------------------------- /API/wwwroot/static/css/main.afd7172b.chunk.css.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":[],"names":[],"mappings":"","file":"main.afd7172b.chunk.css"} -------------------------------------------------------------------------------- /API/wwwroot/static/js/2.8d234b4e.chunk.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /* 2 | object-assign 3 | (c) Sindre Sorhus 4 | @license MIT 5 | */ 6 | 7 | /*! 8 | Copyright (c) 2018 Jed Watson. 9 | Licensed under the MIT License (MIT), see 10 | http://jedwatson.github.io/classnames 11 | */ 12 | 13 | /** @license MUI v5.0.1 14 | * 15 | * This source code is licensed under the MIT license found in the 16 | * LICENSE file in the root directory of this source tree. 17 | */ 18 | 19 | /** @license React v0.20.2 20 | * scheduler.production.min.js 21 | * 22 | * Copyright (c) Facebook, Inc. and its affiliates. 23 | * 24 | * This source code is licensed under the MIT license found in the 25 | * LICENSE file in the root directory of this source tree. 26 | */ 27 | 28 | /** @license React v16.13.1 29 | * react-is.production.min.js 30 | * 31 | * Copyright (c) Facebook, Inc. and its affiliates. 32 | * 33 | * This source code is licensed under the MIT license found in the 34 | * LICENSE file in the root directory of this source tree. 35 | */ 36 | 37 | /** @license React v17.0.2 38 | * react-dom.production.min.js 39 | * 40 | * Copyright (c) Facebook, Inc. and its affiliates. 41 | * 42 | * This source code is licensed under the MIT license found in the 43 | * LICENSE file in the root directory of this source tree. 44 | */ 45 | 46 | /** @license React v17.0.2 47 | * react-is.production.min.js 48 | * 49 | * Copyright (c) Facebook, Inc. and its affiliates. 50 | * 51 | * This source code is licensed under the MIT license found in the 52 | * LICENSE file in the root directory of this source tree. 53 | */ 54 | 55 | /** @license React v17.0.2 56 | * react-jsx-runtime.production.min.js 57 | * 58 | * Copyright (c) Facebook, Inc. and its affiliates. 59 | * 60 | * This source code is licensed under the MIT license found in the 61 | * LICENSE file in the root directory of this source tree. 62 | */ 63 | 64 | /** @license React v17.0.2 65 | * react.production.min.js 66 | * 67 | * Copyright (c) Facebook, Inc. and its affiliates. 68 | * 69 | * This source code is licensed under the MIT license found in the 70 | * LICENSE file in the root directory of this source tree. 71 | */ 72 | -------------------------------------------------------------------------------- /API/wwwroot/static/js/3.831e55aa.chunk.js: -------------------------------------------------------------------------------- 1 | (this.webpackJsonpclient=this.webpackJsonpclient||[]).push([[3],{482:function(t,e,n){"use strict";n.r(e),n.d(e,"getCLS",(function(){return d})),n.d(e,"getFCP",(function(){return S})),n.d(e,"getFID",(function(){return F})),n.d(e,"getLCP",(function(){return k})),n.d(e,"getTTFB",(function(){return C}));var i,a,r,o,u=function(t,e){return{name:t,value:void 0===e?-1:e,delta:0,entries:[],id:"v1-".concat(Date.now(),"-").concat(Math.floor(8999999999999*Math.random())+1e12)}},c=function(t,e){try{if(PerformanceObserver.supportedEntryTypes.includes(t)){if("first-input"===t&&!("PerformanceEventTiming"in self))return;var n=new PerformanceObserver((function(t){return t.getEntries().map(e)}));return n.observe({type:t,buffered:!0}),n}}catch(t){}},f=function(t,e){var n=function n(i){"pagehide"!==i.type&&"hidden"!==document.visibilityState||(t(i),e&&(removeEventListener("visibilitychange",n,!0),removeEventListener("pagehide",n,!0)))};addEventListener("visibilitychange",n,!0),addEventListener("pagehide",n,!0)},s=function(t){addEventListener("pageshow",(function(e){e.persisted&&t(e)}),!0)},m="function"==typeof WeakSet?new WeakSet:new Set,p=function(t,e,n){var i;return function(){e.value>=0&&(n||m.has(e)||"hidden"===document.visibilityState)&&(e.delta=e.value-(i||0),(e.delta||void 0===i)&&(i=e.value,t(e)))}},d=function(t,e){var n,i=u("CLS",0),a=function(t){t.hadRecentInput||(i.value+=t.value,i.entries.push(t),n())},r=c("layout-shift",a);r&&(n=p(t,i,e),f((function(){r.takeRecords().map(a),n()})),s((function(){i=u("CLS",0),n=p(t,i,e)})))},v=-1,l=function(){return"hidden"===document.visibilityState?0:1/0},h=function(){f((function(t){var e=t.timeStamp;v=e}),!0)},g=function(){return v<0&&(v=l(),h(),s((function(){setTimeout((function(){v=l(),h()}),0)}))),{get timeStamp(){return v}}},S=function(t,e){var n,i=g(),a=u("FCP"),r=function(t){"first-contentful-paint"===t.name&&(f&&f.disconnect(),t.startTime=0&&a1e12?new Date:performance.now())-t.timeStamp;"pointerdown"==t.type?function(t,e){var n=function(){w(t,e),a()},i=function(){a()},a=function(){removeEventListener("pointerup",n,y),removeEventListener("pointercancel",i,y)};addEventListener("pointerup",n,y),addEventListener("pointercancel",i,y)}(e,t):w(e,t)}},b=function(t){["mousedown","keydown","touchstart","pointerdown"].forEach((function(e){return t(e,T,y)}))},F=function(t,e){var n,r=g(),d=u("FID"),v=function(t){t.startTime 2 | 3 | 4 | Generated by Fontastic.me 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /API/wwwroot/static/media/slick.29518378.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/API/wwwroot/static/media/slick.29518378.woff -------------------------------------------------------------------------------- /API/wwwroot/static/media/slick.a4e97f5a.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/API/wwwroot/static/media/slick.a4e97f5a.eot -------------------------------------------------------------------------------- /API/wwwroot/static/media/slick.c94f7671.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/API/wwwroot/static/media/slick.c94f7671.ttf -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is the repository for the .Net 6.0, React 16 and React Router 5 version of the course. 2 | 3 | If you are looking for the repository for the latest version of this app created on .Net 7.0 and React v18 then this is available here: 4 | 5 | https://github.com/TryCatchLearn/Restore 6 | -------------------------------------------------------------------------------- /ReStore.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.30114.105 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "API", "API\API.csproj", "{B94C1A08-D289-498A-825E-9B0D3CEC4C4A}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Debug|x64 = Debug|x64 12 | Debug|x86 = Debug|x86 13 | Release|Any CPU = Release|Any CPU 14 | Release|x64 = Release|x64 15 | Release|x86 = Release|x86 16 | EndGlobalSection 17 | GlobalSection(SolutionProperties) = preSolution 18 | HideSolutionNode = FALSE 19 | EndGlobalSection 20 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 21 | {B94C1A08-D289-498A-825E-9B0D3CEC4C4A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 22 | {B94C1A08-D289-498A-825E-9B0D3CEC4C4A}.Debug|Any CPU.Build.0 = Debug|Any CPU 23 | {B94C1A08-D289-498A-825E-9B0D3CEC4C4A}.Debug|x64.ActiveCfg = Debug|Any CPU 24 | {B94C1A08-D289-498A-825E-9B0D3CEC4C4A}.Debug|x64.Build.0 = Debug|Any CPU 25 | {B94C1A08-D289-498A-825E-9B0D3CEC4C4A}.Debug|x86.ActiveCfg = Debug|Any CPU 26 | {B94C1A08-D289-498A-825E-9B0D3CEC4C4A}.Debug|x86.Build.0 = Debug|Any CPU 27 | {B94C1A08-D289-498A-825E-9B0D3CEC4C4A}.Release|Any CPU.ActiveCfg = Release|Any CPU 28 | {B94C1A08-D289-498A-825E-9B0D3CEC4C4A}.Release|Any CPU.Build.0 = Release|Any CPU 29 | {B94C1A08-D289-498A-825E-9B0D3CEC4C4A}.Release|x64.ActiveCfg = Release|Any CPU 30 | {B94C1A08-D289-498A-825E-9B0D3CEC4C4A}.Release|x64.Build.0 = Release|Any CPU 31 | {B94C1A08-D289-498A-825E-9B0D3CEC4C4A}.Release|x86.ActiveCfg = Release|Any CPU 32 | {B94C1A08-D289-498A-825E-9B0D3CEC4C4A}.Release|x86.Build.0 = Release|Any CPU 33 | EndGlobalSection 34 | EndGlobal 35 | -------------------------------------------------------------------------------- /client/.env.development: -------------------------------------------------------------------------------- 1 | REACT_APP_API_URL=http://localhost:5000/api/ -------------------------------------------------------------------------------- /client/.env.production: -------------------------------------------------------------------------------- 1 | REACT_APP_API_URL=/api/ -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@emotion/react": "^11.4.1", 7 | "@emotion/styled": "^11.3.0", 8 | "@hookform/resolvers": "^2.8.1", 9 | "@mui/icons-material": "^5.0.1", 10 | "@mui/lab": "^5.0.0-alpha.48", 11 | "@mui/material": "^5.0.1", 12 | "@reduxjs/toolkit": "^1.6.1", 13 | "@stripe/react-stripe-js": "^1.5.0", 14 | "@stripe/stripe-js": "^1.19.0", 15 | "@testing-library/jest-dom": "^5.14.1", 16 | "@testing-library/react": "^11.2.7", 17 | "@testing-library/user-event": "^12.8.3", 18 | "@types/jest": "^26.0.24", 19 | "@types/node": "^12.20.26", 20 | "@types/react": "^17.0.24", 21 | "@types/react-dom": "^17.0.9", 22 | "@types/react-router-dom": "^5.3.0", 23 | "@types/react-slick": "^0.23.5", 24 | "axios": "^0.21.4", 25 | "react": "^17.0.2", 26 | "react-dom": "^17.0.2", 27 | "react-dropzone": "^11.4.2", 28 | "react-hook-form": "^7.13.0", 29 | "react-redux": "^7.2.5", 30 | "react-router-dom": "^5.3.0", 31 | "react-scripts": "4.0.3", 32 | "react-slick": "^0.28.1", 33 | "react-toastify": "^8.0.2", 34 | "redux": "^4.1.1", 35 | "slick-carousel": "^1.8.1", 36 | "typescript": "^4.4.3", 37 | "web-vitals": "^1.1.2", 38 | "yup": "^0.32.9" 39 | }, 40 | "scripts": { 41 | "start": "react-scripts start", 42 | "build": "BUILD_PATH='../API/wwwroot' react-scripts build", 43 | "test": "react-scripts test", 44 | "eject": "react-scripts eject" 45 | }, 46 | "eslintConfig": { 47 | "extends": [ 48 | "react-app", 49 | "react-app/jest" 50 | ] 51 | }, 52 | "browserslist": { 53 | "production": [ 54 | ">0.2%", 55 | "not dead", 56 | "not op_mini all" 57 | ], 58 | "development": [ 59 | "last 1 chrome version", 60 | "last 1 firefox version", 61 | "last 1 safari version" 62 | ] 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/images/hero1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/hero1.jpg -------------------------------------------------------------------------------- /client/public/images/hero2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/hero2.jpg -------------------------------------------------------------------------------- /client/public/images/hero3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/hero3.jpg -------------------------------------------------------------------------------- /client/public/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/logo.png -------------------------------------------------------------------------------- /client/public/images/placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/placeholder.png -------------------------------------------------------------------------------- /client/public/images/products/boot-ang1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/products/boot-ang1.png -------------------------------------------------------------------------------- /client/public/images/products/boot-ang2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/products/boot-ang2.png -------------------------------------------------------------------------------- /client/public/images/products/boot-core1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/products/boot-core1.png -------------------------------------------------------------------------------- /client/public/images/products/boot-core2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/products/boot-core2.png -------------------------------------------------------------------------------- /client/public/images/products/boot-redis1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/products/boot-redis1.png -------------------------------------------------------------------------------- /client/public/images/products/glove-code1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/products/glove-code1.png -------------------------------------------------------------------------------- /client/public/images/products/glove-code2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/products/glove-code2.png -------------------------------------------------------------------------------- /client/public/images/products/glove-react1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/products/glove-react1.png -------------------------------------------------------------------------------- /client/public/images/products/glove-react2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/products/glove-react2.png -------------------------------------------------------------------------------- /client/public/images/products/hat-core1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/products/hat-core1.png -------------------------------------------------------------------------------- /client/public/images/products/hat-react1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/products/hat-react1.png -------------------------------------------------------------------------------- /client/public/images/products/hat-react2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/products/hat-react2.png -------------------------------------------------------------------------------- /client/public/images/products/sb-ang1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/products/sb-ang1.png -------------------------------------------------------------------------------- /client/public/images/products/sb-ang2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/products/sb-ang2.png -------------------------------------------------------------------------------- /client/public/images/products/sb-core1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/products/sb-core1.png -------------------------------------------------------------------------------- /client/public/images/products/sb-core2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/products/sb-core2.png -------------------------------------------------------------------------------- /client/public/images/products/sb-react1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/products/sb-react1.png -------------------------------------------------------------------------------- /client/public/images/products/sb-ts1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/products/sb-ts1.png -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 15 | 16 | 17 | 26 | ReStore 27 | 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /client/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/logo192.png -------------------------------------------------------------------------------- /client/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/logo512.png -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /client/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /client/src/app/api/agent.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosError, AxiosResponse } from "axios"; 2 | import { toast } from "react-toastify"; 3 | import { history } from "../.."; 4 | import { PaginatedResponse } from "../models/pagination"; 5 | import { store } from "../store/configureStore"; 6 | 7 | const sleep = () => new Promise(resolve => setTimeout(resolve, 500)); 8 | 9 | axios.defaults.baseURL = process.env.REACT_APP_API_URL; 10 | axios.defaults.withCredentials = true; 11 | 12 | const responseBody = (response: AxiosResponse) => response.data; 13 | 14 | axios.interceptors.request.use(config => { 15 | const token = store.getState().account.user?.token; 16 | if (token) config.headers.Authorization = `Bearer ${token}`; 17 | return config; 18 | }) 19 | 20 | axios.interceptors.response.use(async response => { 21 | if (process.env.NODE_ENV === 'development') await sleep(); 22 | const pagination = response.headers['pagination']; 23 | if (pagination) { 24 | response.data = new PaginatedResponse(response.data, JSON.parse(pagination)); 25 | return response; 26 | } 27 | return response; 28 | }, (error: AxiosError) => { 29 | const { data, status } = error.response!; 30 | switch (status) { 31 | case 400: 32 | if (data.errors) { 33 | const modelStateErrors: string[] = []; 34 | for (const key in data.errors) { 35 | if (data.errors[key]) { 36 | modelStateErrors.push(data.errors[key]) 37 | } 38 | } 39 | throw modelStateErrors.flat(); 40 | } 41 | toast.error(data.title); 42 | break; 43 | case 401: 44 | toast.error(data.title); 45 | break; 46 | case 403: 47 | toast.error('You are not allowed to do that!'); 48 | break; 49 | case 500: 50 | history.push({ 51 | pathname: '/server-error', 52 | state: {error: data} 53 | }); 54 | break; 55 | default: 56 | break; 57 | } 58 | return Promise.reject(error.response); 59 | }) 60 | 61 | const requests = { 62 | get: (url: string, params?: URLSearchParams) => axios.get(url, {params}).then(responseBody), 63 | post: (url: string, body: {}) => axios.post(url, body).then(responseBody), 64 | put: (url: string, body: {}) => axios.put(url, body).then(responseBody), 65 | delete: (url: string) => axios.delete(url).then(responseBody), 66 | postForm: (url: string, data: FormData) => axios.post(url, data, { 67 | headers: {'Content-type': 'multipart/form-data'} 68 | }).then(responseBody), 69 | putForm: (url: string, data: FormData) => axios.put(url, data, { 70 | headers: {'Content-type': 'multipart/form-data'} 71 | }).then(responseBody) 72 | } 73 | 74 | function createFormData(item: any) { 75 | let formData = new FormData(); 76 | for (const key in item) { 77 | formData.append(key, item[key]) 78 | } 79 | return formData; 80 | } 81 | 82 | const Admin = { 83 | createProduct: (product: any) => requests.postForm('products', createFormData(product)), 84 | updateProduct: (product: any) => requests.putForm('products', createFormData(product)), 85 | deleteProduct: (id: number) => requests.delete(`products/${id}`) 86 | } 87 | 88 | const Catalog = { 89 | list: (params: URLSearchParams) => requests.get('products', params), 90 | details: (id: number) => requests.get(`products/${id}`), 91 | fetchFilters: () => requests.get('products/filters') 92 | } 93 | 94 | const TestErrors = { 95 | get400Error: () => requests.get('buggy/bad-request'), 96 | get401Error: () => requests.get('buggy/unauthorised'), 97 | get404Error: () => requests.get('buggy/not-found'), 98 | get500Error: () => requests.get('buggy/server-error'), 99 | getValidationError: () => requests.get('buggy/validation-error'), 100 | } 101 | 102 | const Basket = { 103 | get: () => requests.get('basket'), 104 | addItem: (productId: number, quantity = 1) => requests.post(`basket?productId=${productId}&quantity=${quantity}`, {}), 105 | removeItem: (productId: number, quantity = 1) => requests.delete(`basket?productId=${productId}&quantity=${quantity}`) 106 | } 107 | 108 | const Account = { 109 | login: (values: any) => requests.post('account/login', values), 110 | register: (values: any) => requests.post('account/register', values), 111 | currentUser: () => requests.get('account/currentUser'), 112 | fetchAddress: () => requests.get('account/savedAddress') 113 | } 114 | 115 | const Orders = { 116 | list: () => requests.get('orders'), 117 | fetch: (id: number) => requests.get(`orders/${id}`), 118 | create: (values: any) => requests.post('orders', values) 119 | } 120 | 121 | const Payments = { 122 | createPaymentIntent: () => requests.post('payments', {}) 123 | } 124 | 125 | const agent = { 126 | Catalog, 127 | TestErrors, 128 | Basket, 129 | Account, 130 | Orders, 131 | Payments, 132 | Admin 133 | } 134 | 135 | export default agent; -------------------------------------------------------------------------------- /client/src/app/components/AppCheckbox.tsx: -------------------------------------------------------------------------------- 1 | import { Checkbox, FormControlLabel } from "@mui/material"; 2 | import { useController, UseControllerProps } from "react-hook-form" 3 | 4 | interface Props extends UseControllerProps { 5 | label: string; 6 | disabled: boolean; 7 | } 8 | 9 | export default function AppCheckbox(props: Props) { 10 | const {field} = useController({...props, defaultValue: false}); 11 | return ( 12 | 20 | } 21 | label={props.label} 22 | /> 23 | ) 24 | } -------------------------------------------------------------------------------- /client/src/app/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 { useController, UseControllerProps } from 'react-hook-form' 6 | 7 | interface Props extends UseControllerProps { } 8 | 9 | export default function AppDropzone(props: Props) { 10 | const { fieldState, field } = useController({ ...props, defaultValue: null }); 11 | 12 | const dzStyles = { 13 | display: 'flex', 14 | border: 'dashed 3px #eee', 15 | borderColor: '#eee', 16 | borderRadius: '5px', 17 | paddingTop: '30px', 18 | alignItems: 'center', 19 | height: 200, 20 | width: 500 21 | } 22 | 23 | const dzActive = { 24 | borderColor: 'green' 25 | } 26 | 27 | const onDrop = useCallback(acceptedFiles => { 28 | acceptedFiles[0] = Object.assign(acceptedFiles[0], 29 | {preview: URL.createObjectURL(acceptedFiles[0])}); 30 | field.onChange(acceptedFiles[0]); 31 | }, [field]) 32 | const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop }) 33 | 34 | return ( 35 |
36 | 37 | 38 | 39 | Drop image here 40 | {fieldState.error?.message} 41 | 42 |
43 | ) 44 | } -------------------------------------------------------------------------------- /client/src/app/components/AppPagination.tsx: -------------------------------------------------------------------------------- 1 | import { Typography, Pagination } from "@mui/material"; 2 | import { Box } from "@mui/system"; 3 | import { useState } from "react"; 4 | import { MetaData } from "../models/pagination"; 5 | 6 | interface Props { 7 | metaData: MetaData; 8 | onPageChange: (page: number) => void; 9 | } 10 | 11 | export default function AppPagination({metaData, onPageChange}: Props) { 12 | const {currentPage, totalCount, totalPages, pageSize} = metaData; 13 | const [pageNumber, setPageNumber] = useState(currentPage); 14 | 15 | function handlePageChange(page: number) { 16 | setPageNumber(page); 17 | onPageChange(page); 18 | } 19 | 20 | return ( 21 | 22 | 23 | Displaying {(currentPage-1)*pageSize+1}- 24 | {currentPage*pageSize > totalCount 25 | ? totalCount 26 | : currentPage*pageSize} of {totalCount} items 27 | 28 | handlePageChange(page)} 34 | /> 35 | 36 | ) 37 | } -------------------------------------------------------------------------------- /client/src/app/components/AppSelectList.tsx: -------------------------------------------------------------------------------- 1 | import { FormControl, InputLabel, Select, MenuItem, FormHelperText } from "@mui/material"; 2 | import { useController, UseControllerProps } from "react-hook-form"; 3 | 4 | interface Props extends UseControllerProps { 5 | label: string; 6 | items: string[]; 7 | } 8 | 9 | export default function AppSelectList(props: Props) { 10 | const { fieldState, field } = useController({ ...props, defaultValue: '' }); 11 | return ( 12 | 13 | {props.label} 14 | 23 | {fieldState.error?.message} 24 | 25 | ) 26 | } -------------------------------------------------------------------------------- /client/src/app/components/AppTextInput.tsx: -------------------------------------------------------------------------------- 1 | import { TextField } from "@mui/material"; 2 | import { useController, UseControllerProps } from "react-hook-form"; 3 | 4 | interface Props extends UseControllerProps { 5 | label: string; 6 | multiline?: boolean; 7 | rows?: number; 8 | type?: string; 9 | } 10 | 11 | export default function AppTextInput(props: Props) { 12 | const {fieldState, field} = useController({...props, defaultValue: ''}) 13 | return ( 14 | 25 | ) 26 | } -------------------------------------------------------------------------------- /client/src/app/components/CheckboxButtons.tsx: -------------------------------------------------------------------------------- 1 | import { FormGroup, FormControlLabel, Checkbox } from "@mui/material"; 2 | import { useState } from "react"; 3 | 4 | interface 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 | function handleChecked(value: string) { 14 | const currentIndex = checkedItems.findIndex(item => item === value); 15 | let newChecked: string[] = []; 16 | if (currentIndex === -1) newChecked = [...checkedItems, value]; 17 | else newChecked = checkedItems.filter(item => item !== value); 18 | setCheckedItems(newChecked); 19 | onChange(newChecked); 20 | } 21 | 22 | return ( 23 | 24 | {items.map(item => ( 25 | handleChecked(item)} 29 | />} 30 | label={item} 31 | key={item} 32 | /> 33 | ))} 34 | 35 | ) 36 | } -------------------------------------------------------------------------------- /client/src/app/components/RadioButtonGroup.tsx: -------------------------------------------------------------------------------- 1 | import { FormControl, RadioGroup, FormControlLabel, Radio } from "@mui/material"; 2 | 3 | interface Props { 4 | options: any[]; 5 | onChange: (event: any) => void; 6 | selectedValue: string; 7 | } 8 | 9 | export default function RadioButtonGroup({options, onChange, selectedValue}: Props) { 10 | return ( 11 | 12 | 13 | {options.map(({ value, label }) => ( 14 | } label={label} key={value} /> 15 | ))} 16 | 17 | 18 | ) 19 | } -------------------------------------------------------------------------------- /client/src/app/context/StoreContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, PropsWithChildren, useContext, useState } from "react"; 2 | import { Basket } from "../models/basket"; 3 | 4 | interface StoreContextValue { 5 | basket: Basket | null; 6 | setBasket: (basket: Basket) => void; 7 | removeItem: (productId: number, quantity: number) => void; 8 | } 9 | 10 | export const StoreContext = createContext(undefined); 11 | 12 | export function useStoreContext() { 13 | const context = useContext(StoreContext); 14 | 15 | if (context === undefined) { 16 | throw Error('Oops - we do not seem to be inside the provider'); 17 | } 18 | 19 | return context; 20 | } 21 | 22 | export function StoreProvider({children}: PropsWithChildren) { 23 | const [basket, setBasket] = useState(null); 24 | 25 | function removeItem(productId: number, quantity: number) { 26 | if (!basket) return; 27 | const items = [...basket.items]; 28 | const itemIndex = items.findIndex(i => i.productId === productId); 29 | if (itemIndex >= 0) { 30 | items[itemIndex].quantity -= quantity; 31 | if (items[itemIndex].quantity === 0) items.splice(itemIndex, 1); 32 | setBasket(prevState => { 33 | return {...prevState!, items} 34 | }) 35 | } 36 | } 37 | 38 | return ( 39 | 40 | {children} 41 | 42 | ) 43 | } -------------------------------------------------------------------------------- /client/src/app/errors/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Container, Divider, Paper, Typography } from "@mui/material"; 2 | import { Link } from "react-router-dom"; 3 | 4 | export default function NotFound() { 5 | return ( 6 | 7 | Oops - we could not find what you are looking for 8 | 9 | 10 | 11 | ) 12 | } -------------------------------------------------------------------------------- /client/src/app/errors/ServerError.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Container, Divider, Paper, Typography } from "@mui/material"; 2 | import { useHistory, useLocation } from "react-router"; 3 | 4 | export default function ServerError() { 5 | const history = useHistory(); 6 | const { state } = useLocation(); 7 | 8 | return ( 9 | 10 | {state?.error ? ( 11 | <> 12 | {state.error.title} 13 | 14 | {state.error.detail || 'Internal server error'} 15 | 16 | ) : ( 17 | Server Error 18 | )} 19 | 20 | 21 | ) 22 | } -------------------------------------------------------------------------------- /client/src/app/hooks/useProducts.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { productSelectors, fetchProductsAsync, fetchFilters } from "../../features/catalog/catalogSlice"; 3 | import { useAppSelector, useAppDispatch } from "../store/configureStore"; 4 | 5 | export default function useProducts() { 6 | const products = useAppSelector(productSelectors.selectAll); 7 | const { productsLoaded, filtersLoaded, brands, types, metaData } = useAppSelector(state => state.catalog); 8 | const dispatch = useAppDispatch(); 9 | 10 | useEffect(() => { 11 | if (!productsLoaded) dispatch(fetchProductsAsync()); 12 | }, [productsLoaded, dispatch]) 13 | 14 | useEffect(() => { 15 | if (!filtersLoaded) dispatch(fetchFilters()); 16 | }, [filtersLoaded, dispatch]); 17 | 18 | return { 19 | products, 20 | productsLoaded, 21 | filtersLoaded, 22 | brands, 23 | types, 24 | metaData 25 | } 26 | } -------------------------------------------------------------------------------- /client/src/app/layout/App.tsx: -------------------------------------------------------------------------------- 1 | import { Container, createTheme, CssBaseline, ThemeProvider } from "@mui/material"; 2 | import { useCallback, useEffect, useState } from "react"; 3 | import { Route, Switch } from "react-router"; 4 | import { ToastContainer } from "react-toastify"; 5 | import AboutPage from "../../features/about/AboutPage"; 6 | import Catalog from "../../features/catalog/Catalog"; 7 | import ProductDetails from "../../features/catalog/ProductDetails"; 8 | import ContactPage from "../../features/contact/ContactPage"; 9 | import HomePage from "../../features/home/HomePage"; 10 | import Header from "./Header"; 11 | import 'react-toastify/dist/ReactToastify.css'; 12 | import ServerError from "../errors/ServerError"; 13 | import NotFound from "../errors/NotFound"; 14 | import BasketPage from "../../features/basket/BasketPage"; 15 | import LoadingComponent from "./LoadingComponent"; 16 | import { useAppDispatch } from "../store/configureStore"; 17 | import { fetchBasketAsync } from "../../features/basket/basketSlice"; 18 | import Login from "../../features/account/Login"; 19 | import Register from "../../features/account/Register"; 20 | import { fetchCurrentUser } from "../../features/account/accountSlice"; 21 | import PrivateRoute from "./PrivateRoute"; 22 | import Orders from "../../features/orders/Orders"; 23 | import CheckoutWrapper from "../../features/checkout/CheckoutWrapper"; 24 | import Inventory from "../../features/admin/Inventory"; 25 | 26 | function App() { 27 | const dispatch = useAppDispatch(); 28 | const [loading, setLoading] = useState(true); 29 | 30 | const initApp = useCallback(async () => { 31 | try { 32 | await dispatch(fetchCurrentUser()); 33 | await dispatch(fetchBasketAsync()); 34 | } catch (error) { 35 | console.log(error); 36 | } 37 | }, [dispatch]) 38 | 39 | useEffect(() => { 40 | initApp().then(() => setLoading(false)); 41 | }, [initApp]) 42 | 43 | const [darkMode, setDarkMode] = useState(false); 44 | const paletteType = darkMode ? 'dark' : 'light' 45 | const theme = createTheme({ 46 | palette: { 47 | mode: paletteType, 48 | background: { 49 | default: paletteType === 'light' ? '#eaeaea' : '#121212' 50 | } 51 | } 52 | }) 53 | 54 | function handleThemeChange() { 55 | setDarkMode(!darkMode); 56 | } 57 | 58 | if (loading) return 59 | 60 | return ( 61 | 62 | 63 | 64 |
65 | 66 | ( 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | )} /> 84 | 85 | 86 | ); 87 | } 88 | 89 | export default App; 90 | -------------------------------------------------------------------------------- /client/src/app/layout/Header.tsx: -------------------------------------------------------------------------------- 1 | import { ShoppingCart } from "@mui/icons-material"; 2 | import { AppBar, Badge, Box, IconButton, List, ListItem, Switch, Toolbar, Typography } from "@mui/material"; 3 | import { Link, NavLink } from "react-router-dom"; 4 | import { useAppSelector } from "../store/configureStore"; 5 | import SignedInMenu from "./SignedInMenu"; 6 | 7 | interface Props { 8 | darkMode: boolean; 9 | handleThemeChange: () => void; 10 | } 11 | 12 | const midLinks = [ 13 | { title: 'catalog', path: '/catalog' }, 14 | { title: 'about', path: '/about' }, 15 | { title: 'contact', path: '/contact' } 16 | ] 17 | 18 | const rightLinks = [ 19 | { title: 'login', path: '/login' }, 20 | { title: 'register', path: '/register' } 21 | ] 22 | 23 | const navStyles = { 24 | color: 'inherit', 25 | textDecoration: 'none', 26 | typography: 'h6', 27 | '&:hover': { 28 | color: 'grey.500' 29 | }, 30 | '&.active': { 31 | color: 'text.secondary' 32 | } 33 | } 34 | 35 | export default function Header({ darkMode, handleThemeChange }: Props) { 36 | const { basket } = useAppSelector(state => state.basket); 37 | const { user } = useAppSelector(state => state.account); 38 | const itemCount = basket?.items.reduce((sum, item) => sum + item.quantity, 0) 39 | 40 | return ( 41 | 42 | 43 | 44 | 46 | RE-STORE 47 | 48 | 49 | 50 | 51 | {midLinks.map(({ title, path }) => ( 52 | 58 | {title.toUpperCase()} 59 | 60 | ))} 61 | {user && user.roles?.includes('Admin') && 62 | 67 | INVENTORY 68 | } 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | {user ? ( 77 | 78 | ) : ( 79 | 80 | {rightLinks.map(({ title, path }) => ( 81 | 87 | {title.toUpperCase()} 88 | 89 | ))} 90 | 91 | )} 92 | 93 | 94 | 95 | ) 96 | } -------------------------------------------------------------------------------- /client/src/app/layout/LoadingComponent.tsx: -------------------------------------------------------------------------------- 1 | import { Backdrop, CircularProgress, Typography } from "@mui/material"; 2 | import { Box } from "@mui/system"; 3 | 4 | interface Props { 5 | message?: string; 6 | } 7 | 8 | export default function LoadingComponent({message = 'Loading...'}: Props) { 9 | return ( 10 | 11 | 12 | 13 | 14 | {message} 15 | 16 | 17 | 18 | ) 19 | } -------------------------------------------------------------------------------- /client/src/app/layout/PrivateRoute.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentType } from "react"; 2 | import { Redirect, Route, RouteComponentProps, RouteProps } from "react-router"; 3 | import { toast } from "react-toastify"; 4 | import { useAppSelector } from "../store/configureStore"; 5 | 6 | interface Props extends RouteProps { 7 | component: ComponentType> | ComponentType; 8 | roles?: string[]; 9 | } 10 | 11 | export default function PrivateRoute({ component: Component, roles, ...rest }: Props) { 12 | const { user } = useAppSelector(state => state.account); 13 | return ( 14 | { 15 | if (!user) { 16 | return 17 | } 18 | 19 | if (roles && !roles?.some(r => user.roles?.includes(r))) { 20 | toast.error('Not authorised to access this area'); 21 | return 22 | } 23 | 24 | return 25 | }} 26 | /> 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /client/src/app/layout/SignedInMenu.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Menu, Fade, MenuItem } from "@mui/material"; 2 | import React from "react"; 3 | import { Link } from "react-router-dom"; 4 | import { signOut } from "../../features/account/accountSlice"; 5 | import { clearBasket } from "../../features/basket/basketSlice"; 6 | import { useAppDispatch, useAppSelector } from "../store/configureStore"; 7 | 8 | export default function SignedInMenu() { 9 | const dispatch = useAppDispatch(); 10 | const { user } = useAppSelector(state => state.account); 11 | const [anchorEl, setAnchorEl] = React.useState(null); 12 | const open = Boolean(anchorEl); 13 | const handleClick = (event: any) => { 14 | setAnchorEl(event.currentTarget); 15 | }; 16 | const handleClose = () => { 17 | setAnchorEl(null); 18 | }; 19 | 20 | return ( 21 | <> 22 | 29 | 35 | Profile 36 | My orders 37 | { 38 | dispatch(signOut()); 39 | dispatch(clearBasket()); 40 | }}>Logout 41 | 42 | 43 | ); 44 | } -------------------------------------------------------------------------------- /client/src/app/layout/styles.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/src/app/layout/styles.css -------------------------------------------------------------------------------- /client/src/app/models/basket.ts: -------------------------------------------------------------------------------- 1 | export interface BasketItem { 2 | productId: number; 3 | name: string; 4 | price: number; 5 | pictureUrl: string; 6 | brand: string; 7 | type: string; 8 | quantity: number; 9 | } 10 | 11 | export interface Basket { 12 | id: number; 13 | buyerId: string; 14 | items: BasketItem[]; 15 | paymentIntentId?: string; 16 | clientSecret?: string; 17 | } 18 | -------------------------------------------------------------------------------- /client/src/app/models/order.ts: -------------------------------------------------------------------------------- 1 | export interface ShippingAddress { 2 | fullName: string; 3 | address1: string; 4 | address2: string; 5 | city: string; 6 | state: string; 7 | zip: string; 8 | country: string; 9 | } 10 | 11 | export interface OrderItem { 12 | productId: number; 13 | name: string; 14 | pictureUrl: string; 15 | price: number; 16 | quantity: number; 17 | } 18 | 19 | export interface Order { 20 | id: number; 21 | buyerId: string; 22 | shippingAddress: ShippingAddress; 23 | orderDate: string; 24 | orderItems: OrderItem[]; 25 | subtotal: number; 26 | deliveryFee: number; 27 | orderStatus: string; 28 | total: number; 29 | } 30 | -------------------------------------------------------------------------------- /client/src/app/models/pagination.ts: -------------------------------------------------------------------------------- 1 | export interface MetaData { 2 | currentPage: number; 3 | totalPages: number; 4 | pageSize: number; 5 | totalCount: number; 6 | } 7 | 8 | export class PaginatedResponse { 9 | items: T; 10 | metaData: MetaData; 11 | 12 | constructor(items: T, metaData: MetaData) { 13 | this.items = items; 14 | this.metaData = metaData; 15 | } 16 | } -------------------------------------------------------------------------------- /client/src/app/models/product.ts: -------------------------------------------------------------------------------- 1 | export interface 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 | } 11 | 12 | export interface ProductParams { 13 | orderBy: string; 14 | searchTerm?: string; 15 | types: string[]; 16 | brands: string[]; 17 | pageNumber: number; 18 | pageSize: number; 19 | } -------------------------------------------------------------------------------- /client/src/app/models/user.ts: -------------------------------------------------------------------------------- 1 | import { Basket } from "./basket"; 2 | 3 | export interface User { 4 | email: string; 5 | token: string; 6 | basket?: Basket; 7 | roles?: string[]; 8 | } -------------------------------------------------------------------------------- /client/src/app/store/configureStore.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from "@reduxjs/toolkit"; 2 | import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; 3 | import { accountSlice } from "../../features/account/accountSlice"; 4 | import { basketSlice } from "../../features/basket/basketSlice"; 5 | import { catalogSlice } from "../../features/catalog/catalogSlice"; 6 | import { counterSlice } from "../../features/contact/counterSlice"; 7 | 8 | // export function configureStore() { 9 | // return createStore(counterReducer); 10 | // } 11 | 12 | export const store = configureStore({ 13 | reducer: { 14 | counter: counterSlice.reducer, 15 | basket: basketSlice.reducer, 16 | catalog: catalogSlice.reducer, 17 | account: accountSlice.reducer 18 | } 19 | }) 20 | 21 | export type RootState = ReturnType; 22 | export type AppDispatch = typeof store.dispatch; 23 | 24 | export const useAppDispatch = () => useDispatch(); 25 | export const useAppSelector: TypedUseSelectorHook = useSelector; -------------------------------------------------------------------------------- /client/src/app/util/util.ts: -------------------------------------------------------------------------------- 1 | export function getCookie(key: string) { 2 | const b = document.cookie.match("(^|;)\\s*" + key + "\\s*=\\s*([^;]+)"); 3 | return b ? b.pop() : ""; 4 | } 5 | 6 | export function currencyFormat(amount: number) { 7 | return '$' + (amount/100).toFixed(2); 8 | } -------------------------------------------------------------------------------- /client/src/features/about/AboutPage.tsx: -------------------------------------------------------------------------------- 1 | import { Alert, AlertTitle, Button, ButtonGroup, Container, List, ListItem, ListItemText, Typography } from "@mui/material"; 2 | import { useState } from "react"; 3 | import agent from "../../app/api/agent"; 4 | 5 | export default function AboutPage() { 6 | const [validationErrors, setValidationErrors] = useState([]); 7 | 8 | function getValidationError() { 9 | agent.TestErrors.getValidationError() 10 | .then(() => console.log('should not see this')) 11 | .catch(error => setValidationErrors(error)); 12 | } 13 | 14 | return ( 15 | 16 | Errors for testing purposes 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | {validationErrors.length > 0 && 25 | 26 | Validation Errors 27 | 28 | {validationErrors.map(error => ( 29 | 30 | {error} 31 | 32 | ))} 33 | 34 | 35 | } 36 | 37 | ) 38 | } -------------------------------------------------------------------------------- /client/src/features/account/Login.tsx: -------------------------------------------------------------------------------- 1 | import Avatar from '@mui/material/Avatar'; 2 | import TextField from '@mui/material/TextField'; 3 | import Grid from '@mui/material/Grid'; 4 | import Box from '@mui/material/Box'; 5 | import LockOutlinedIcon from '@mui/icons-material/LockOutlined'; 6 | import Typography from '@mui/material/Typography'; 7 | import Container from '@mui/material/Container'; 8 | import { Paper } from '@mui/material'; 9 | import { Link, useHistory, useLocation } from 'react-router-dom'; 10 | import { FieldValues, useForm } from 'react-hook-form'; 11 | import { LoadingButton } from '@mui/lab'; 12 | import { useAppDispatch } from '../../app/store/configureStore'; 13 | import { signInUser } from './accountSlice'; 14 | 15 | export default function Login() { 16 | const history = useHistory(); 17 | const location = useLocation(); 18 | const dispatch = useAppDispatch(); 19 | const { register, handleSubmit, formState: { isSubmitting, errors, isValid } } = useForm({ 20 | mode: 'all' 21 | }); 22 | 23 | async function submitForm(data: FieldValues) { 24 | try { 25 | await dispatch(signInUser(data)); 26 | history.push(location.state?.from?.pathname || '/catalog'); 27 | } catch (error) { 28 | console.log(error); 29 | } 30 | } 31 | 32 | return ( 33 | 34 | 35 | 36 | 37 | 38 | Sign in 39 | 40 | 41 | 50 | 59 | 67 | Sign In 68 | 69 | 70 | 71 | 72 | {"Don't have an account? Sign Up"} 73 | 74 | 75 | 76 | 77 | 78 | ); 79 | } -------------------------------------------------------------------------------- /client/src/features/account/Register.tsx: -------------------------------------------------------------------------------- 1 | import Avatar from '@mui/material/Avatar'; 2 | import TextField from '@mui/material/TextField'; 3 | import Grid from '@mui/material/Grid'; 4 | import Box from '@mui/material/Box'; 5 | import LockOutlinedIcon from '@mui/icons-material/LockOutlined'; 6 | import Typography from '@mui/material/Typography'; 7 | import Container from '@mui/material/Container'; 8 | import { Paper } from '@mui/material'; 9 | import { Link, useHistory } from 'react-router-dom'; 10 | import { useForm } from 'react-hook-form'; 11 | import { LoadingButton } from '@mui/lab'; 12 | import agent from '../../app/api/agent'; 13 | import { toast } from 'react-toastify'; 14 | 15 | export default function Register() { 16 | const history = useHistory(); 17 | const { register, handleSubmit, setError, formState: { isSubmitting, errors, isValid } } = useForm({ 18 | mode: 'all' 19 | }); 20 | 21 | function handleApiErrors(errors: any) { 22 | if (errors) { 23 | errors.forEach((error: string) => { 24 | if (error.includes('Password')) { 25 | setError('password', { message: error }) 26 | } else if (error.includes('Email')) { 27 | setError('email', { message: error }) 28 | } else if (error.includes('Username')) { 29 | setError('username', { message: error }) 30 | } 31 | }); 32 | } 33 | } 34 | 35 | return ( 36 | 37 | 38 | 39 | 40 | 41 | Register 42 | 43 | 45 | agent.Account.register(data) 46 | .then(() => { 47 | toast.success('Registration successful - you can now login'); 48 | history.push('/login'); 49 | }) 50 | .catch(error => handleApiErrors(error)) 51 | )} 52 | noValidate sx={{ mt: 1 }} 53 | > 54 | 63 | 77 | 92 | 100 | Register 101 | 102 | 103 | 104 | 105 | {"Already have an account? Sign In"} 106 | 107 | 108 | 109 | 110 | 111 | ); 112 | } -------------------------------------------------------------------------------- /client/src/features/account/accountSlice.ts: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk, createSlice, isAnyOf } from "@reduxjs/toolkit"; 2 | import { FieldValues } from "react-hook-form"; 3 | import { toast } from "react-toastify"; 4 | import { history } from "../.."; 5 | import agent from "../../app/api/agent"; 6 | import { User } from "../../app/models/user"; 7 | import { setBasket } from "../basket/basketSlice"; 8 | 9 | interface AccountState { 10 | user: User | null; 11 | } 12 | 13 | const initialState: AccountState = { 14 | user: null 15 | } 16 | 17 | export const signInUser = createAsyncThunk( 18 | 'account/signInUser', 19 | async (data, thunkAPI) => { 20 | try { 21 | const userDto = await agent.Account.login(data); 22 | const {basket, ...user} = userDto; 23 | if (basket) thunkAPI.dispatch(setBasket(basket)); 24 | localStorage.setItem('user', JSON.stringify(user)); 25 | return user; 26 | } catch (error: any) { 27 | return thunkAPI.rejectWithValue({error: error.data}); 28 | } 29 | } 30 | ) 31 | 32 | export const fetchCurrentUser = createAsyncThunk( 33 | 'account/fetchCurrentUser', 34 | async (_, thunkAPI) => { 35 | thunkAPI.dispatch(setUser(JSON.parse(localStorage.getItem('user')!))); 36 | try { 37 | const userDto = await agent.Account.currentUser(); 38 | const {basket, ...user} = userDto; 39 | if (basket) thunkAPI.dispatch(setBasket(basket)); 40 | localStorage.setItem('user', JSON.stringify(user)); 41 | return user; 42 | } catch (error: any) { 43 | return thunkAPI.rejectWithValue({error: error.data}); 44 | } 45 | }, 46 | { 47 | condition: () => { 48 | if (!localStorage.getItem('user')) return false; 49 | } 50 | } 51 | ) 52 | 53 | export const accountSlice = createSlice({ 54 | name: 'account', 55 | initialState, 56 | reducers: { 57 | signOut: (state) => { 58 | state.user = null; 59 | localStorage.removeItem('user'); 60 | history.push('/'); 61 | }, 62 | setUser: (state, action) => { 63 | let claims = JSON.parse(atob(action.payload.token.split('.')[1])); 64 | let roles = claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/role']; 65 | state.user = {...action.payload, roles: typeof(roles) === 'string' ? [roles] : roles}; 66 | } 67 | }, 68 | extraReducers: (builder => { 69 | builder.addCase(fetchCurrentUser.rejected, (state) => { 70 | state.user = null; 71 | localStorage.removeItem('user'); 72 | toast.error('Session expired - please login again'); 73 | history.push('/'); 74 | }); 75 | builder.addMatcher(isAnyOf(signInUser.fulfilled, fetchCurrentUser.fulfilled), (state, action) => { 76 | let claims = JSON.parse(atob(action.payload.token.split('.')[1])); 77 | let roles = claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/role']; 78 | state.user = {...action.payload, roles: typeof(roles) === 'string' ? [roles] : roles}; 79 | }); 80 | builder.addMatcher(isAnyOf(signInUser.rejected), (state, action) => { 81 | throw action.payload; 82 | }) 83 | }) 84 | }) 85 | 86 | export const {signOut, setUser} = accountSlice.actions; -------------------------------------------------------------------------------- /client/src/features/admin/Inventory.tsx: -------------------------------------------------------------------------------- 1 | import { Typography, Button, TableContainer, Paper, Table, TableHead, TableRow, TableCell, TableBody, Box } from "@mui/material"; 2 | import { Edit, Delete } from "@mui/icons-material"; 3 | import { currencyFormat } from "../../app/util/util"; 4 | import useProducts from "../../app/hooks/useProducts"; 5 | import AppPagination from "../../app/components/AppPagination"; 6 | import { useAppDispatch } from "../../app/store/configureStore"; 7 | import { removeProduct, setPageNumber } from "../catalog/catalogSlice"; 8 | import { useState } from "react"; 9 | import ProductForm from "./ProductForm"; 10 | import { Product } from "../../app/models/product"; 11 | import agent from "../../app/api/agent"; 12 | import { LoadingButton } from "@mui/lab"; 13 | 14 | export default function Inventory() { 15 | const {products, metaData} = useProducts(); 16 | const dispatch = useAppDispatch(); 17 | const [editMode, setEditMode] = useState(false); 18 | const [selectedProduct, setSelectedProduct] = useState(undefined); 19 | const [loading, setLoading] = useState(false); 20 | const [target, setTarget] = useState(0); 21 | 22 | function handleSelectProduct(product: Product) { 23 | setSelectedProduct(product); 24 | setEditMode(true); 25 | } 26 | 27 | function handleDeleteProduct(id: number) { 28 | setLoading(true); 29 | setTarget(id); 30 | agent.Admin.deleteProduct(id) 31 | .then(() => dispatch(removeProduct(id))) 32 | .catch(error => console.log(error)) 33 | .finally(() => setLoading(false)); 34 | } 35 | 36 | function cancelEdit() { 37 | if (selectedProduct) setSelectedProduct(undefined); 38 | setEditMode(false); 39 | } 40 | 41 | if (editMode) return 42 | 43 | return ( 44 | <> 45 | 46 | Inventory 47 | 48 | 49 | 50 | 51 | 52 | 53 | # 54 | Product 55 | Price 56 | Type 57 | Brand 58 | Quantity 59 | 60 | 61 | 62 | 63 | {products.map((product) => ( 64 | 68 | 69 | {product.id} 70 | 71 | 72 | 73 | {product.name} 74 | {product.name} 75 | 76 | 77 | {currencyFormat(product.price)} 78 | {product.type} 79 | {product.brand} 80 | {product.quantityInStock} 81 | 82 |
93 |
94 | {metaData && 95 | 96 | dispatch(setPageNumber({pageNumber: page}))} 99 | /> 100 | 101 | } 102 | 103 | ) 104 | } -------------------------------------------------------------------------------- /client/src/features/admin/ProductForm.tsx: -------------------------------------------------------------------------------- 1 | import { Typography, Grid, Paper, Box, Button } from "@mui/material"; 2 | import { useEffect } from "react"; 3 | import { FieldValues, useForm } from "react-hook-form"; 4 | import AppDropzone from "../../app/components/AppDropzone"; 5 | import AppSelectList from "../../app/components/AppSelectList"; 6 | import AppTextInput from "../../app/components/AppTextInput"; 7 | import useProducts from "../../app/hooks/useProducts"; 8 | import { Product } from "../../app/models/product"; 9 | import {yupResolver} from '@hookform/resolvers/yup'; 10 | import { validationSchema } from "./productValidation"; 11 | import agent from "../../app/api/agent"; 12 | import { useAppDispatch } from "../../app/store/configureStore"; 13 | import { setProduct } from "../catalog/catalogSlice"; 14 | import { LoadingButton } from "@mui/lab"; 15 | 16 | interface Props { 17 | product?: Product; 18 | cancelEdit: () => void; 19 | } 20 | 21 | export default function ProductForm({ product, cancelEdit }: Props) { 22 | const { control, reset, handleSubmit, watch, formState: {isDirty, isSubmitting} } = useForm({ 23 | mode: 'all', 24 | resolver: yupResolver(validationSchema) 25 | }); 26 | const { brands, types } = useProducts(); 27 | const watchFile = watch('file', null); 28 | const dispatch = useAppDispatch(); 29 | 30 | useEffect(() => { 31 | if (product && !watchFile && !isDirty) reset(product); 32 | return () => { 33 | if (watchFile) URL.revokeObjectURL(watchFile.preview); 34 | } 35 | }, [product, reset, watchFile, isDirty]); 36 | 37 | async function handleSubmitData(data: FieldValues) { 38 | try { 39 | let response: Product; 40 | if (product) { 41 | response = await agent.Admin.updateProduct(data); 42 | } else { 43 | response = await agent.Admin.createProduct(data); 44 | } 45 | dispatch(setProduct(response)); 46 | cancelEdit(); 47 | } catch (error) { 48 | console.log(error) 49 | } 50 | } 51 | 52 | return ( 53 | 54 | 55 | Product Details 56 | 57 |
58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | {watchFile ? ( 81 | preview 82 | ) : ( 83 | {product?.name} 84 | )} 85 | 86 | 87 | 88 | 89 | 90 | 91 | Submit 92 | 93 |
94 |
95 | ) 96 | } -------------------------------------------------------------------------------- /client/src/features/admin/productValidation.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | export const validationSchema = yup.object({ 4 | name: yup.string().required(), 5 | brand: yup.string().required(), 6 | type: yup.string().required(), 7 | price: yup.number().required().moreThan(100), 8 | quantityInStock: yup.number().required().min(0), 9 | description: yup.string().required(), 10 | file: yup.mixed().when('pictureUrl', { 11 | is: (value: string) => !value, 12 | then: yup.mixed().required('Please provide an image') 13 | }) 14 | }) -------------------------------------------------------------------------------- /client/src/features/basket/BasketPage.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Grid, Typography } from "@mui/material"; 2 | import { Link } from "react-router-dom"; 3 | import { useAppSelector } from "../../app/store/configureStore"; 4 | import BasketSummary from "./BasketSummary"; 5 | import BasketTable from "./BasketTable"; 6 | 7 | export default function BasketPage() { 8 | const { basket } = useAppSelector(state => state.basket); 9 | 10 | if (!basket) return Your basket is empty 11 | 12 | return ( 13 | <> 14 | 15 | 16 | 17 | 18 | 19 | 28 | 29 | 30 | 31 | 32 | ) 33 | } -------------------------------------------------------------------------------- /client/src/features/basket/BasketSummary.tsx: -------------------------------------------------------------------------------- 1 | import { TableContainer, Paper, Table, TableBody, TableRow, TableCell } from "@mui/material"; 2 | import { useAppSelector } from "../../app/store/configureStore"; 3 | import { currencyFormat } from "../../app/util/util"; 4 | 5 | interface Props { 6 | subtotal?: number; 7 | } 8 | 9 | export default function BasketSummary({subtotal}: Props) { 10 | const {basket} = useAppSelector(state => state.basket); 11 | if (subtotal === undefined) 12 | subtotal = basket?.items.reduce((sum, item) => sum + (item.quantity * item.price), 0) ?? 0; 13 | const deliveryFee = subtotal > 10000 ? 0 : 500; 14 | 15 | return ( 16 | <> 17 | 18 | 19 | 20 | 21 | Subtotal 22 | {currencyFormat(subtotal)} 23 | 24 | 25 | Delivery fee* 26 | {currencyFormat(deliveryFee)} 27 | 28 | 29 | Total 30 | {currencyFormat(subtotal + deliveryFee)} 31 | 32 | 33 | 34 | *Orders over $100 qualify for free delivery 35 | 36 | 37 | 38 |
39 |
40 | 41 | ) 42 | } -------------------------------------------------------------------------------- /client/src/features/basket/BasketTable.tsx: -------------------------------------------------------------------------------- 1 | import { Remove, Add, Delete } from "@mui/icons-material"; 2 | import { LoadingButton } from "@mui/lab"; 3 | import { TableContainer, Paper, Table, TableHead, TableRow, TableCell, TableBody } from "@mui/material"; 4 | import { Box } from "@mui/system"; 5 | import { BasketItem } from "../../app/models/basket"; 6 | import { useAppSelector, useAppDispatch } from "../../app/store/configureStore"; 7 | import { removeBasketItemAsync, addBasketItemAsync } from "./basketSlice"; 8 | 9 | interface Props { 10 | items: BasketItem[]; 11 | isBasket?: boolean; 12 | } 13 | 14 | export default function BasketTable({ items, isBasket = true }: Props) { 15 | const { status } = useAppSelector(state => state.basket); 16 | const dispatch = useAppDispatch(); 17 | return ( 18 | 19 | 20 | 21 | 22 | Product 23 | Price 24 | Quantity 25 | Subtotal 26 | {isBasket && 27 | } 28 | 29 | 30 | 31 | {items.map(item => ( 32 | 36 | 37 | 38 | {item.name} 39 | {item.name} 40 | 41 | 42 | ${(item.price / 100).toFixed(2)} 43 | 44 | {isBasket && 45 | dispatch(removeBasketItemAsync({ productId: item.productId, quantity: 1, name: 'rem' }))} 48 | color='error' 49 | > 50 | 51 | } 52 | {item.quantity} 53 | {isBasket && 54 | dispatch(addBasketItemAsync({ productId: item.productId }))} 57 | color='secondary' 58 | > 59 | 60 | } 61 | 62 | ${((item.price / 100) * item.quantity).toFixed(2)} 63 | {isBasket && 64 | 65 | dispatch(removeBasketItemAsync({ productId: item.productId, quantity: item.quantity, name: 'del' }))} 68 | color='error' 69 | > 70 | 71 | 72 | } 73 | 74 | ))} 75 | 76 |
77 |
78 | ) 79 | } -------------------------------------------------------------------------------- /client/src/features/basket/basketSlice.ts: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk, createSlice, isAnyOf } from "@reduxjs/toolkit"; 2 | import agent from "../../app/api/agent"; 3 | import { Basket } from "../../app/models/basket"; 4 | import { getCookie } from "../../app/util/util"; 5 | 6 | interface BasketState { 7 | basket: Basket | null; 8 | status: string; 9 | } 10 | 11 | const initialState: BasketState = { 12 | basket: null, 13 | status: 'idle' 14 | } 15 | 16 | export const fetchBasketAsync = createAsyncThunk( 17 | 'basket/fetchBasketAsync', 18 | async (_, thunkAPI) => { 19 | try { 20 | return await agent.Basket.get(); 21 | } catch (error: any) { 22 | return thunkAPI.rejectWithValue({error: error.data}); 23 | } 24 | }, 25 | { 26 | condition: () => { 27 | if (!getCookie('buyerId')) return false; 28 | } 29 | } 30 | ) 31 | 32 | export const addBasketItemAsync = createAsyncThunk( 33 | 'basket/addBasketItemAsync', 34 | async ({productId, quantity = 1}, thunkAPI) => { 35 | try { 36 | return await agent.Basket.addItem(productId, quantity); 37 | } catch (error: any) { 38 | return thunkAPI.rejectWithValue({error: error.data}) 39 | } 40 | } 41 | ) 42 | 43 | export const removeBasketItemAsync = createAsyncThunk( 45 | 'basket/removeBasketItemAsync', 46 | async ({productId, quantity}, thunkAPI) => { 47 | try { 48 | await agent.Basket.removeItem(productId, quantity); 49 | } catch (error: any) { 50 | return thunkAPI.rejectWithValue({error: error.data}) 51 | } 52 | } 53 | ) 54 | 55 | export const basketSlice = createSlice({ 56 | name: 'basket', 57 | initialState, 58 | reducers: { 59 | setBasket: (state, action) => { 60 | state.basket = action.payload 61 | }, 62 | clearBasket: (state) => { 63 | state.basket = null; 64 | } 65 | }, 66 | extraReducers: (builder => { 67 | builder.addCase(addBasketItemAsync.pending, (state, action) => { 68 | state.status = 'pendingAddItem' + action.meta.arg.productId; 69 | }); 70 | builder.addCase(removeBasketItemAsync.pending, (state, action) => { 71 | state.status = 'pendingRemoveItem' + action.meta.arg.productId + action.meta.arg.name; 72 | }); 73 | builder.addCase(removeBasketItemAsync.fulfilled, (state, action) => { 74 | const {productId, quantity} = action.meta.arg; 75 | const itemIndex = state.basket?.items.findIndex(i => i.productId === productId); 76 | if (itemIndex === -1 || itemIndex === undefined) return; 77 | state.basket!.items[itemIndex].quantity -= quantity; 78 | if (state.basket?.items[itemIndex].quantity === 0) 79 | state.basket.items.splice(itemIndex, 1); 80 | state.status = 'idle'; 81 | }); 82 | builder.addCase(removeBasketItemAsync.rejected, (state, action) => { 83 | console.log(action.payload); 84 | state.status = 'idle'; 85 | }); 86 | builder.addMatcher(isAnyOf(addBasketItemAsync.fulfilled, fetchBasketAsync.fulfilled), (state, action) => { 87 | state.basket = action.payload; 88 | state.status = 'idle'; 89 | }); 90 | builder.addMatcher(isAnyOf(addBasketItemAsync.rejected, fetchBasketAsync.rejected), (state, action) => { 91 | console.log(action.payload); 92 | state.status = 'idle'; 93 | }); 94 | }) 95 | }) 96 | 97 | export const {setBasket, clearBasket} = basketSlice.actions; -------------------------------------------------------------------------------- /client/src/features/catalog/Catalog.tsx: -------------------------------------------------------------------------------- 1 | import { Grid, Paper } from "@mui/material"; 2 | import AppPagination from "../../app/components/AppPagination"; 3 | import CheckboxButtons from "../../app/components/CheckboxButtons"; 4 | import RadioButtonGroup from "../../app/components/RadioButtonGroup"; 5 | import useProducts from "../../app/hooks/useProducts"; 6 | import LoadingComponent from "../../app/layout/LoadingComponent"; 7 | import { useAppDispatch, useAppSelector } from "../../app/store/configureStore"; 8 | import { setPageNumber, setProductParams } from "./catalogSlice"; 9 | import ProductList from "./ProductList"; 10 | import ProductSearch from "./ProductSearch"; 11 | 12 | const sortOptions = [ 13 | { value: 'name', label: 'Alphabetical' }, 14 | { value: 'priceDesc', label: 'Price - High to low' }, 15 | { value: 'price', label: 'Price - Low to high' }, 16 | ] 17 | 18 | export default function Catalog() { 19 | const {products, brands, types, filtersLoaded, metaData} = useProducts(); 20 | const { productParams, } = useAppSelector(state => state.catalog); 21 | const dispatch = useAppDispatch(); 22 | 23 | if (!filtersLoaded) return 24 | 25 | return ( 26 | 27 | 28 | 29 | 30 | 31 | 32 | dispatch(setProductParams({ orderBy: e.target.value }))} 36 | /> 37 | 38 | 39 | dispatch(setProductParams({ brands: items }))} 43 | /> 44 | 45 | 46 | dispatch(setProductParams({ types: items }))} 50 | /> 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | {metaData && 59 | dispatch(setPageNumber({pageNumber: page}))} 62 | />} 63 | 64 | 65 | ) 66 | } -------------------------------------------------------------------------------- /client/src/features/catalog/ProductCard.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingButton } from "@mui/lab"; 2 | import { Avatar, Button, Card, CardActions, CardContent, CardHeader, CardMedia, Typography } from "@mui/material"; 3 | import { Link } from "react-router-dom"; 4 | import { Product } from "../../app/models/product"; 5 | import { useAppDispatch, useAppSelector } from "../../app/store/configureStore"; 6 | import { currencyFormat } from "../../app/util/util"; 7 | import { addBasketItemAsync } from "../basket/basketSlice"; 8 | 9 | interface Props { 10 | product: Product 11 | } 12 | 13 | export default function ProductCard({ product }: Props) { 14 | const {status} = useAppSelector(state => state.basket); 15 | const dispatch = useAppDispatch(); 16 | 17 | return ( 18 | 19 | 22 | {product.name.charAt(0).toUpperCase()} 23 | 24 | } 25 | title={product.name} 26 | titleTypographyProps={{ 27 | sx: { fontWeight: 'bold', color: 'primary.main' } 28 | }} 29 | /> 30 | 35 | 36 | 37 | {currencyFormat(product.price)} 38 | 39 | 40 | {product.brand} / {product.type} 41 | 42 | 43 | 44 | dispatch(addBasketItemAsync({productId: product.id}))} 47 | size="small"> 48 | Add to cart 49 | 50 | 51 | 52 | 53 | ) 54 | } -------------------------------------------------------------------------------- /client/src/features/catalog/ProductCardSkeleton.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | CardActions, 4 | CardContent, 5 | CardHeader, 6 | Grid, 7 | Skeleton 8 | } from "@mui/material"; 9 | 10 | export default function ProductCardSkeleton() { 11 | return ( 12 | 13 | 16 | } 17 | title={ 18 | 24 | } 25 | /> 26 | 27 | 28 | <> 29 | 30 | 31 | 32 | 33 | 34 | <> 35 | 36 | 37 | 38 | 39 | 40 | ) 41 | } -------------------------------------------------------------------------------- /client/src/features/catalog/ProductDetails.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingButton } from "@mui/lab"; 2 | import { Divider, Grid, Table, TableBody, TableCell, TableContainer, TableRow, TextField, Typography } from "@mui/material"; 3 | import { useEffect, useState } from "react"; 4 | import { useParams } from "react-router"; 5 | import NotFound from "../../app/errors/NotFound"; 6 | import LoadingComponent from "../../app/layout/LoadingComponent"; 7 | import { useAppDispatch, useAppSelector } from "../../app/store/configureStore"; 8 | import { addBasketItemAsync, removeBasketItemAsync } from "../basket/basketSlice"; 9 | import { fetchProductAsync, productSelectors } from "./catalogSlice"; 10 | 11 | export default function ProductDetails() { 12 | const {basket, status} = useAppSelector(state => state.basket); 13 | const dispatch = useAppDispatch(); 14 | const {id} = useParams<{id: string}>(); 15 | const product = useAppSelector(state => productSelectors.selectById(state, id)); 16 | const {status: productStatus} = useAppSelector(state => state.catalog); 17 | const [quantity, setQuantity] = useState(0); 18 | const item = basket?.items.find(i => i.productId === product?.id); 19 | 20 | useEffect(() => { 21 | if (item) setQuantity(item.quantity); 22 | if (!product) dispatch(fetchProductAsync(parseInt(id))) 23 | }, [id, item, dispatch, product]); 24 | 25 | function handleInputChange(event: any) { 26 | if (event.target.value > 0) { 27 | setQuantity(parseInt(event.target.value)); 28 | } 29 | } 30 | 31 | function handleUpdateCart() { 32 | if (!item || quantity > item.quantity) { 33 | const updatedQuantity = item ? quantity - item.quantity : quantity; 34 | dispatch(addBasketItemAsync({productId: product?.id!, quantity: updatedQuantity})) 35 | } else { 36 | const updatedQuantity = item.quantity - quantity; 37 | dispatch(removeBasketItemAsync({productId: product?.id!, quantity: updatedQuantity})) 38 | } 39 | } 40 | 41 | if (productStatus.includes('pending')) return 42 | 43 | if (!product) return 44 | 45 | return ( 46 | 47 | 48 | {product.name} 49 | 50 | 51 | {product.name} 52 | 53 | ${(product.price / 100).toFixed(2)} 54 | 55 | 56 | 57 | 58 | Name 59 | {product.name} 60 | 61 | 62 | Description 63 | {product.description} 64 | 65 | 66 | Type 67 | {product.type} 68 | 69 | 70 | Brand 71 | {product.brand} 72 | 73 | 74 | Quantity in stock 75 | {product.quantityInStock} 76 | 77 | 78 |
79 |
80 | 81 | 82 | 90 | 91 | 92 | 102 | {item ? 'Update Quantity' : 'Add to Cart'} 103 | 104 | 105 | 106 |
107 |
108 | ) 109 | } -------------------------------------------------------------------------------- /client/src/features/catalog/ProductList.tsx: -------------------------------------------------------------------------------- 1 | import { Grid } from "@mui/material"; 2 | import { Product } from "../../app/models/product"; 3 | import { useAppSelector } from "../../app/store/configureStore"; 4 | import ProductCard from "./ProductCard"; 5 | import ProductCardSkeleton from "./ProductCardSkeleton"; 6 | 7 | interface Props { 8 | products: Product[]; 9 | } 10 | 11 | export default function ProductList({ products }: Props) { 12 | const { productsLoaded } = useAppSelector(state => state.catalog); 13 | return ( 14 | 15 | {products.map(product => ( 16 | 17 | {!productsLoaded ? ( 18 | 19 | ) : ( 20 | 21 | )} 22 | 23 | ))} 24 | 25 | ) 26 | } -------------------------------------------------------------------------------- /client/src/features/catalog/ProductSearch.tsx: -------------------------------------------------------------------------------- 1 | import { debounce, TextField } from "@mui/material"; 2 | import { useState } from "react"; 3 | import { useAppDispatch, useAppSelector } from "../../app/store/configureStore"; 4 | import { setProductParams } from "./catalogSlice"; 5 | 6 | export default function ProductSearch() { 7 | const {productParams} = useAppSelector(state => state.catalog); 8 | const [searchTerm, setSearchTerm] = useState(productParams.searchTerm); 9 | const dispatch = useAppDispatch(); 10 | 11 | const debouncedSearch = debounce((event: any) => { 12 | dispatch(setProductParams({searchTerm: event.target.value})) 13 | }, 1000) 14 | 15 | return ( 16 | { 22 | setSearchTerm(event.target.value); 23 | debouncedSearch(event); 24 | }} 25 | /> 26 | ) 27 | } -------------------------------------------------------------------------------- /client/src/features/catalog/catalogSlice.ts: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk, createEntityAdapter, createSlice } from "@reduxjs/toolkit"; 2 | import agent from "../../app/api/agent"; 3 | import { MetaData } from "../../app/models/pagination"; 4 | import { Product, ProductParams } from "../../app/models/product"; 5 | import { RootState } from "../../app/store/configureStore"; 6 | 7 | interface CatalogState { 8 | productsLoaded: boolean; 9 | filtersLoaded: boolean; 10 | status: string; 11 | brands: string[]; 12 | types: string[]; 13 | productParams: ProductParams; 14 | metaData: MetaData | null; 15 | } 16 | 17 | const productsAdapter = createEntityAdapter(); 18 | 19 | function getAxiosParams(productParams: ProductParams) { 20 | const params = new URLSearchParams(); 21 | params.append('pageNumber', productParams.pageNumber.toString()); 22 | params.append('pageSize', productParams.pageSize.toString()); 23 | params.append('orderBy', productParams.orderBy); 24 | if (productParams.searchTerm) params.append('searchTerm', productParams.searchTerm); 25 | if (productParams.brands.length > 0) params.append('brands', productParams.brands.toString()); 26 | if (productParams.types.length > 0) params.append('types', productParams.types.toString()); 27 | return params; 28 | } 29 | 30 | export const fetchProductsAsync = createAsyncThunk( 31 | 'catalog/fetchProductsAsync', 32 | async (_, thunkAPI) => { 33 | const params = getAxiosParams(thunkAPI.getState().catalog.productParams); 34 | try { 35 | const response = await agent.Catalog.list(params); 36 | thunkAPI.dispatch(setMetaData(response.metaData)); 37 | return response.items; 38 | } catch (error: any) { 39 | return thunkAPI.rejectWithValue({error: error.data}) 40 | } 41 | } 42 | ) 43 | 44 | export const fetchProductAsync = createAsyncThunk( 45 | 'catalog/fetchProductAsync', 46 | async (productId, thunkAPI) => { 47 | try { 48 | return await agent.Catalog.details(productId); 49 | } catch (error: any) { 50 | return thunkAPI.rejectWithValue({error: error.data}) 51 | } 52 | } 53 | ) 54 | 55 | export const fetchFilters = createAsyncThunk( 56 | 'catalog/fetchFilters', 57 | async (_, thunkAPI) => { 58 | try { 59 | return agent.Catalog.fetchFilters(); 60 | } catch (error: any) { 61 | return thunkAPI.rejectWithValue({error: error.data}) 62 | } 63 | } 64 | ) 65 | 66 | function initParams() { 67 | return { 68 | pageNumber: 1, 69 | pageSize: 6, 70 | orderBy: 'name', 71 | brands: [], 72 | types: [] 73 | } 74 | } 75 | 76 | export const catalogSlice = createSlice({ 77 | name: 'catalog', 78 | initialState: productsAdapter.getInitialState({ 79 | productsLoaded: false, 80 | filtersLoaded: false, 81 | status: 'idle', 82 | brands: [], 83 | types: [], 84 | productParams: initParams(), 85 | metaData: null 86 | }), 87 | reducers: { 88 | setProductParams: (state, action) => { 89 | state.productsLoaded = false; 90 | state.productParams = {...state.productParams, ...action.payload, pageNumber: 1}; 91 | }, 92 | setPageNumber: (state, action) => { 93 | state.productsLoaded = false; 94 | state.productParams = {...state.productParams, ...action.payload}; 95 | }, 96 | setMetaData: (state, action) => { 97 | state.metaData = action.payload; 98 | }, 99 | resetProductParams: (state) => { 100 | state.productParams = initParams(); 101 | }, 102 | setProduct: (state, action) => { 103 | productsAdapter.upsertOne(state, action.payload); 104 | state.productsLoaded = false; 105 | }, 106 | removeProduct: (state, action) => { 107 | productsAdapter.removeOne(state, action.payload); 108 | state.productsLoaded = false; 109 | } 110 | }, 111 | extraReducers: (builder => { 112 | builder.addCase(fetchProductsAsync.pending, (state) => { 113 | state.status = 'pendingFetchProducts'; 114 | }); 115 | builder.addCase(fetchProductsAsync.fulfilled, (state, action) => { 116 | productsAdapter.setAll(state, action.payload); 117 | state.status = 'idle'; 118 | state.productsLoaded = true; 119 | }); 120 | builder.addCase(fetchProductsAsync.rejected, (state, action) => { 121 | console.log(action.payload); 122 | state.status = 'idle'; 123 | }); 124 | builder.addCase(fetchProductAsync.pending, (state) => { 125 | state.status = 'pendingFetchProduct'; 126 | }); 127 | builder.addCase(fetchProductAsync.fulfilled, (state, action) => { 128 | productsAdapter.upsertOne(state, action.payload); 129 | state.status = 'idle'; 130 | }); 131 | builder.addCase(fetchProductAsync.rejected, (state, action) => { 132 | console.log(action); 133 | state.status = 'idle'; 134 | }); 135 | builder.addCase(fetchFilters.pending, (state) => { 136 | state.status = 'pendingFetchFilters'; 137 | }); 138 | builder.addCase(fetchFilters.fulfilled, (state, action) => { 139 | state.brands = action.payload.brands; 140 | state.types = action.payload.types; 141 | state.filtersLoaded = true; 142 | state.status = 'idle'; 143 | }); 144 | builder.addCase(fetchFilters.rejected, (state, action) => { 145 | state.status = 'idle'; 146 | console.log(action.payload); 147 | }) 148 | }) 149 | }) 150 | 151 | export const productSelectors = productsAdapter.getSelectors((state: RootState) => state.catalog); 152 | 153 | export const {setProductParams, resetProductParams, setMetaData, setPageNumber, setProduct, removeProduct} = catalogSlice.actions; -------------------------------------------------------------------------------- /client/src/features/checkout/AddressForm.tsx: -------------------------------------------------------------------------------- 1 | import Grid from '@mui/material/Grid'; 2 | import Typography from '@mui/material/Typography'; 3 | import { useFormContext } from 'react-hook-form'; 4 | import AppTextInput from '../../app/components/AppTextInput'; 5 | import AppCheckbox from '../../app/components/AppCheckbox'; 6 | 7 | export default function AddressForm() { 8 | const { control, formState } = useFormContext(); 9 | return ( 10 | <> 11 | 12 | Shipping address 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 43 | 44 | 45 | 46 | ); 47 | } -------------------------------------------------------------------------------- /client/src/features/checkout/CheckoutWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { Elements } from "@stripe/react-stripe-js"; 2 | import { loadStripe } from "@stripe/stripe-js"; 3 | import { useEffect, useState } from "react"; 4 | import agent from "../../app/api/agent"; 5 | import LoadingComponent from "../../app/layout/LoadingComponent"; 6 | import { useAppDispatch } from "../../app/store/configureStore"; 7 | import { setBasket } from "../basket/basketSlice"; 8 | import CheckoutPage from "./CheckoutPage"; 9 | 10 | const stripePromise = loadStripe("pk_test_51IzwHFErFg8RLNropkfWpnL37TzyR3eTpn0vY0EmatAeBwxlNPFJT2e2VtfIt2V8975y2W7kC1gcQ5tB5B332Y2x00yktsLIxN") 11 | 12 | export default function CheckoutWrapper() { 13 | const dispatch = useAppDispatch(); 14 | const [loading, setLoading] = useState(true); 15 | 16 | useEffect(() => { 17 | agent.Payments.createPaymentIntent() 18 | .then(basket => dispatch(setBasket(basket))) 19 | .catch(error => console.log(error)) 20 | .finally(() => setLoading(false)); 21 | }, [dispatch]); 22 | 23 | if (loading) return 24 | 25 | return ( 26 | 27 | 28 | 29 | ) 30 | } -------------------------------------------------------------------------------- /client/src/features/checkout/PaymentForm.tsx: -------------------------------------------------------------------------------- 1 | import Typography from '@mui/material/Typography'; 2 | import Grid from '@mui/material/Grid'; 3 | import TextField from '@mui/material/TextField'; 4 | import { useFormContext } from 'react-hook-form'; 5 | import AppTextInput from '../../app/components/AppTextInput'; 6 | import { CardCvcElement, CardExpiryElement, CardNumberElement } from '@stripe/react-stripe-js'; 7 | import { StripeInput } from './StripeInput'; 8 | import { StripeElementType } from '@stripe/stripe-js'; 9 | 10 | interface Props { 11 | cardState: { elementError: { [key in StripeElementType]?: string } }; 12 | onCardInputChange: (event: any) => void; 13 | } 14 | 15 | export default function PaymentForm({cardState, onCardInputChange}: Props) { 16 | const { control } = useFormContext(); 17 | 18 | return ( 19 | <> 20 | 21 | Payment method 22 | 23 | 24 | 25 | 26 | 27 | 28 | 44 | 45 | 46 | 63 | 64 | 65 | 82 | 83 | 84 | 85 | ); 86 | } -------------------------------------------------------------------------------- /client/src/features/checkout/Review.tsx: -------------------------------------------------------------------------------- 1 | import { Grid } from '@mui/material'; 2 | import Typography from '@mui/material/Typography'; 3 | import { useAppSelector } from '../../app/store/configureStore'; 4 | import BasketSummary from '../basket/BasketSummary'; 5 | import BasketTable from '../basket/BasketTable'; 6 | 7 | export default function Review() { 8 | const {basket} = useAppSelector(state => state.basket); 9 | return ( 10 | <> 11 | 12 | Order summary 13 | 14 | {basket && 15 | } 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ); 24 | } -------------------------------------------------------------------------------- /client/src/features/checkout/StripeInput.tsx: -------------------------------------------------------------------------------- 1 | import { InputBaseComponentProps } from "@mui/material"; 2 | import { forwardRef, Ref, useImperativeHandle, useRef } from "react"; 3 | 4 | interface Props extends InputBaseComponentProps {} 5 | 6 | export const StripeInput = forwardRef(function StripeInput({component: Component, ...props}: Props, 7 | ref: Ref){ 8 | const elementRef = useRef(); 9 | 10 | useImperativeHandle(ref, () => ({ 11 | focus: () => elementRef.current.focus 12 | })); 13 | 14 | return ( 15 | elementRef.current = element} 17 | {...props} 18 | /> 19 | ) 20 | }); -------------------------------------------------------------------------------- /client/src/features/checkout/checkoutValidation.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | export const validationSchema = [ 4 | yup.object({ 5 | fullName: yup.string().required('Full name is required'), 6 | address1: yup.string().required('Addres line 1 is required'), 7 | address2: yup.string().required(), 8 | city: yup.string().required(), 9 | state: yup.string().required(), 10 | zip: yup.string().required(), 11 | country: yup.string().required(), 12 | }), 13 | yup.object(), 14 | yup.object({ 15 | nameOnCard: yup.string().required() 16 | }) 17 | ] -------------------------------------------------------------------------------- /client/src/features/contact/ContactPage.tsx: -------------------------------------------------------------------------------- 1 | import { Button, ButtonGroup, Typography } from "@mui/material"; 2 | import { useAppDispatch, useAppSelector } from "../../app/store/configureStore"; 3 | import { decrement, increment } from "./counterSlice"; 4 | 5 | export default function ContactPage() { 6 | const dispatch = useAppDispatch(); 7 | const { data, title } = useAppSelector(state => state.counter); 8 | 9 | return ( 10 | <> 11 | 12 | {title} 13 | 14 | 15 | The data is: {data} 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | ) 25 | } -------------------------------------------------------------------------------- /client/src/features/contact/counterReducer.ts: -------------------------------------------------------------------------------- 1 | export const INCREMENT_COUNTER = "INCREMENT_COUNTER"; 2 | export const DECREMENT_COUNTER = "DECREMENT_COUNTER"; 3 | 4 | export interface CounterState { 5 | data: number; 6 | title: string; 7 | } 8 | 9 | const initialState: CounterState = { 10 | data: 42, 11 | title: 'YARC (yet another redux counter)' 12 | } 13 | 14 | export function increment(amount = 1) { 15 | return { 16 | type: INCREMENT_COUNTER, 17 | payload: amount 18 | } 19 | } 20 | 21 | export function decrement(amount = 1) { 22 | return { 23 | type: DECREMENT_COUNTER, 24 | payload: amount 25 | } 26 | } 27 | 28 | export default function counterReducer(state = initialState, action: any) { 29 | switch (action.type) { 30 | case INCREMENT_COUNTER: 31 | return { 32 | ...state, 33 | data: state.data + action.payload 34 | } 35 | case DECREMENT_COUNTER: 36 | return { 37 | ...state, 38 | data: state.data - action.payload 39 | } 40 | default: 41 | return state; 42 | } 43 | } -------------------------------------------------------------------------------- /client/src/features/contact/counterSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit" 2 | 3 | export interface CounterState { 4 | data: number; 5 | title: string; 6 | } 7 | 8 | const initialState: CounterState = { 9 | data: 42, 10 | title: 'YARC (yet another redux counter with redux toolkit)' 11 | } 12 | 13 | export const counterSlice = createSlice({ 14 | name: 'counter', 15 | initialState, 16 | reducers: { 17 | increment: (state, action) => { 18 | state.data += action.payload 19 | }, 20 | decrement: (state, action) => { 21 | state.data -= action.payload 22 | } 23 | } 24 | }) 25 | 26 | export const {increment, decrement} = counterSlice.actions; -------------------------------------------------------------------------------- /client/src/features/home/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Typography } from "@mui/material"; 2 | import Slider from "react-slick"; 3 | 4 | export default function HomePage() { 5 | const settings = { 6 | dots: true, 7 | infinite: true, 8 | speed: 500, 9 | slidesToShow: 1, 10 | slidesToScroll: 1 11 | }; 12 | 13 | return ( 14 | <> 15 | 16 |
17 | hero 18 |
19 |
20 | hero 21 |
22 |
23 | hero 24 |
25 |
26 | 27 | Welcome to the store 28 | 29 | 30 | ) 31 | } -------------------------------------------------------------------------------- /client/src/features/orders/OrderDetailed.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Grid, Typography } from "@mui/material"; 2 | import { Box } from "@mui/system"; 3 | import { BasketItem } from "../../app/models/basket"; 4 | import { Order } from "../../app/models/order"; 5 | import BasketSummary from "../basket/BasketSummary"; 6 | import BasketTable from "../basket/BasketTable"; 7 | 8 | interface Props { 9 | order: Order; 10 | setSelectedOrder: (id: number) => void; 11 | } 12 | 13 | export default function OrderDetailed({ order, setSelectedOrder }: Props) { 14 | const subtotal = order.orderItems.reduce((sum, item) => sum + (item.quantity * item.price), 0) ?? 0; 15 | return ( 16 | <> 17 | 18 | Order# {order.id} - {order.orderStatus} 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | ) 30 | } -------------------------------------------------------------------------------- /client/src/features/orders/Orders.tsx: -------------------------------------------------------------------------------- 1 | import { TableContainer, Paper, Table, TableHead, TableRow, TableCell, TableBody, Button } from "@mui/material"; 2 | import { useEffect, useState } from "react"; 3 | import agent from "../../app/api/agent"; 4 | import LoadingComponent from "../../app/layout/LoadingComponent"; 5 | import { Order } from "../../app/models/order"; 6 | import { currencyFormat } from "../../app/util/util"; 7 | import OrderDetailed from "./OrderDetailed"; 8 | 9 | export default function Orders() { 10 | const [orders, setOrders] = useState(null); 11 | const [loading, setLoading] = useState(true); 12 | const [selectedOrderNumber, setSelectedOrderNumber] = useState(0); 13 | 14 | useEffect(() => { 15 | agent.Orders.list() 16 | .then(orders => setOrders(orders)) 17 | .catch(error => console.log(error)) 18 | .finally(() => setLoading(false)); 19 | }, []) 20 | 21 | if (loading) return 22 | 23 | if (selectedOrderNumber > 0) return ( 24 | o.id === selectedOrderNumber)!} 26 | setSelectedOrder={setSelectedOrderNumber} 27 | /> 28 | ) 29 | 30 | return ( 31 | 32 | 33 | 34 | 35 | Order number 36 | Total 37 | Order Date 38 | Order Status 39 | 40 | 41 | 42 | 43 | {orders?.map((order) => ( 44 | 48 | 49 | {order.id} 50 | 51 | {currencyFormat(order.total)} 52 | {order.orderDate.split('T')[0]} 53 | {order.orderStatus} 54 | 55 | 58 | 59 | 60 | ))} 61 | 62 |
63 |
64 | ) 65 | } -------------------------------------------------------------------------------- /client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './app/layout/styles.css'; 4 | import App from './app/layout/App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | import { Router } from 'react-router-dom'; 7 | import { createBrowserHistory } from 'history'; 8 | import { Provider } from 'react-redux'; 9 | import { store } from './app/store/configureStore'; 10 | import 'slick-carousel/slick/slick.css'; 11 | import 'slick-carousel/slick/slick-theme.css'; 12 | 13 | export const history = createBrowserHistory(); 14 | 15 | ReactDOM.render( 16 | 17 | 18 | 19 | 20 | 21 | 22 | , 23 | document.getElementById('root') 24 | ); 25 | 26 | // If you want to start measuring performance in your app, pass a function 27 | // to log results (for example: reportWebVitals(console.log)) 28 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 29 | reportWebVitals(); 30 | -------------------------------------------------------------------------------- /client/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /client/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /client/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | --------------------------------------------------------------------------------