diff --git a/.env.example b/.env.example index 4c015fb..2e29a38 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,7 @@ BACKEND_DATASOURCE_URL=jdbc:postgresql://pinehaus-database-staging:5432/database BACKEND_DATASOURCE_USERNAME=admin BACKEND_DATASOURCE_PASSWORD=123 GOOGLE_CLIENT_ID=id.apps.googleusercontent.com +IMAGE_PATH=C:\Users\jerza\Documents\code\test FRONTEND_COOKIE_DOMAIN=localhost FRONTEND_URL=https://localhost:3000 diff --git a/.github/workflows/backend.prod.yml b/.github/workflows/backend.prod.yml index 5600c60..98f13e2 100644 --- a/.github/workflows/backend.prod.yml +++ b/.github/workflows/backend.prod.yml @@ -18,10 +18,10 @@ jobs: runs-on: [self-hosted] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up JDK 21 - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: "21" distribution: "corretto" @@ -32,7 +32,7 @@ jobs: maven-version: 3.9.6 - name: Set up the Maven dependencies caching - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ~/.m2 key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} @@ -52,7 +52,7 @@ jobs: runs-on: [self-hosted] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup .env file env: diff --git a/.github/workflows/backend.staging.yml b/.github/workflows/backend.staging.yml index fb14947..d9050eb 100644 --- a/.github/workflows/backend.staging.yml +++ b/.github/workflows/backend.staging.yml @@ -18,10 +18,10 @@ jobs: runs-on: [self-hosted] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up JDK 21 - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: "21" distribution: "corretto" @@ -32,7 +32,7 @@ jobs: maven-version: 3.9.6 - name: Set up the Maven dependencies caching - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ~/.m2 key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} @@ -52,7 +52,7 @@ jobs: runs-on: [self-hosted] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup .env file env: diff --git a/.github/workflows/frontend.prod.yml b/.github/workflows/frontend.prod.yml index e600c20..bddcfb7 100644 --- a/.github/workflows/frontend.prod.yml +++ b/.github/workflows/frontend.prod.yml @@ -18,8 +18,8 @@ jobs: runs-on: [self-hosted] steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: 18.17 @@ -42,7 +42,7 @@ jobs: runs-on: [self-hosted] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup .env file env: diff --git a/.github/workflows/frontend.staging.yml b/.github/workflows/frontend.staging.yml index eee47a9..87243d9 100644 --- a/.github/workflows/frontend.staging.yml +++ b/.github/workflows/frontend.staging.yml @@ -19,8 +19,8 @@ jobs: runs-on: [self-hosted] steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: 18.17 @@ -43,7 +43,7 @@ jobs: runs-on: [self-hosted] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup .env file env: diff --git a/backend/Dockerfile b/backend/Dockerfile index 8b06ee7..96cd382 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -14,6 +14,9 @@ WORKDIR /app COPY --from=builder /app/target/*.jar app.jar +# Directory that is mapped to CDN on host machine +RUN mkdir -p /pinehaus/images + EXPOSE 8080 ENTRYPOINT ["java", "--enable-preview", "-jar", "app.jar" ] \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/attribute/controller/AttributeController.java b/backend/src/main/java/net/pinehaus/backend/attribute/controller/AttributeController.java new file mode 100644 index 0000000..879cf9d --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/attribute/controller/AttributeController.java @@ -0,0 +1,25 @@ +package net.pinehaus.backend.attribute.controller; + +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import lombok.RequiredArgsConstructor; +import net.pinehaus.backend.attribute.model.Attribute; +import net.pinehaus.backend.attribute.service.AttributeService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/attributes") +@Tag(name = "Product attributes") +@RequiredArgsConstructor +public class AttributeController { + + private final AttributeService attributeService; + + @GetMapping + public List getAllAttributes() { + return attributeService.getAllAttributes(); + } + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/attribute/dto/AttributeValueDTO.java b/backend/src/main/java/net/pinehaus/backend/attribute/dto/AttributeValueDTO.java new file mode 100644 index 0000000..96ba212 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/attribute/dto/AttributeValueDTO.java @@ -0,0 +1,15 @@ +package net.pinehaus.backend.attribute.dto; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@RequiredArgsConstructor +public class AttributeValueDTO { + + private int attributeId; + private String value; + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/attribute/model/Attribute.java b/backend/src/main/java/net/pinehaus/backend/attribute/model/Attribute.java new file mode 100644 index 0000000..9100ceb --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/attribute/model/Attribute.java @@ -0,0 +1,54 @@ +package net.pinehaus.backend.attribute.model; + +import com.fasterxml.jackson.annotation.JsonView; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import java.util.List; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; + +@Entity +@RequiredArgsConstructor +@JsonView(AttributeViews.Public.class) +public class Attribute { + + @Id + @Getter + @Setter + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int id; + + @Setter + @Getter + @Column(nullable = false) + private String name; + + @Setter + @Getter + @Column(nullable = false) + private AttributeType type; + + /** + * Options for the attribute. Only used if the attribute type is ENUM. Contains a comma-separated + * list of options. + */ + @Column + private String options; + + public List getOptions() { + if (options == null) { + return null; + } + + return List.of(options.split(",")); + } + + public void setOptions(List options) { + this.options = String.join(",", options); + } + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/attribute/model/AttributeType.java b/backend/src/main/java/net/pinehaus/backend/attribute/model/AttributeType.java new file mode 100644 index 0000000..ef3d805 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/attribute/model/AttributeType.java @@ -0,0 +1,5 @@ +package net.pinehaus.backend.attribute.model; + +public enum AttributeType { + ENUM, NUMBER, BOOLEAN, STRING +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/attribute/model/AttributeValue.java b/backend/src/main/java/net/pinehaus/backend/attribute/model/AttributeValue.java new file mode 100644 index 0000000..154b4fc --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/attribute/model/AttributeValue.java @@ -0,0 +1,47 @@ +package net.pinehaus.backend.attribute.model; + +import com.fasterxml.jackson.annotation.JsonView; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import java.io.Serializable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import net.pinehaus.backend.product.model.Product; + +@Entity +@Getter +@Setter +@RequiredArgsConstructor +@IdClass(AttributeValue.AttributeValueId.class) +public class AttributeValue { + + @Id + @ManyToOne + @JoinColumn + @JsonView(AttributeValueViews.Public.class) + private Attribute attribute; + + @Id + @ManyToOne + @JoinColumn + private Product product; + + @Column + @JsonView(AttributeValueViews.Public.class) + private String value; + + @Getter + @Setter + @RequiredArgsConstructor + public static class AttributeValueId implements Serializable { + + private int attribute; + private int product; + } + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/attribute/model/AttributeValueViews.java b/backend/src/main/java/net/pinehaus/backend/attribute/model/AttributeValueViews.java new file mode 100644 index 0000000..8228da2 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/attribute/model/AttributeValueViews.java @@ -0,0 +1,9 @@ +package net.pinehaus.backend.attribute.model; + +public class AttributeValueViews { + + public interface Public extends AttributeViews.Public { + + } + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/attribute/model/AttributeViews.java b/backend/src/main/java/net/pinehaus/backend/attribute/model/AttributeViews.java new file mode 100644 index 0000000..25b4b81 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/attribute/model/AttributeViews.java @@ -0,0 +1,9 @@ +package net.pinehaus.backend.attribute.model; + +public class AttributeViews { + + public interface Public { + + } + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/attribute/repository/AttributeRepository.java b/backend/src/main/java/net/pinehaus/backend/attribute/repository/AttributeRepository.java new file mode 100644 index 0000000..ef78659 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/attribute/repository/AttributeRepository.java @@ -0,0 +1,11 @@ +package net.pinehaus.backend.attribute.repository; + +import java.util.Optional; +import net.pinehaus.backend.attribute.model.Attribute; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AttributeRepository extends JpaRepository { + + Optional findById(int id); + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/attribute/repository/AttributeValueRepository.java b/backend/src/main/java/net/pinehaus/backend/attribute/repository/AttributeValueRepository.java new file mode 100644 index 0000000..71f0ea8 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/attribute/repository/AttributeValueRepository.java @@ -0,0 +1,21 @@ +package net.pinehaus.backend.attribute.repository; + +import java.util.List; +import java.util.Optional; +import net.pinehaus.backend.attribute.model.Attribute; +import net.pinehaus.backend.attribute.model.AttributeValue; +import net.pinehaus.backend.product.model.Product; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AttributeValueRepository extends + JpaRepository { + + void deleteByAttributeAndProduct(Attribute attribute, Product product); + + void deleteByAttributeIdAndProductId(int attributeId, int productId); + + Optional findByAttributeIdAndProductId(int attributeId, int productId); + + List findByProductId(int productId); + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/attribute/service/AttributeService.java b/backend/src/main/java/net/pinehaus/backend/attribute/service/AttributeService.java new file mode 100644 index 0000000..b230348 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/attribute/service/AttributeService.java @@ -0,0 +1,24 @@ +package net.pinehaus.backend.attribute.service; + +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import net.pinehaus.backend.attribute.model.Attribute; +import net.pinehaus.backend.attribute.repository.AttributeRepository; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AttributeService { + + private final AttributeRepository attributeRepository; + + public List getAllAttributes() { + return attributeRepository.findAll(); + } + + public Optional getById(int id) { + return attributeRepository.findById(id); + } + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/attribute/service/AttributeValueService.java b/backend/src/main/java/net/pinehaus/backend/attribute/service/AttributeValueService.java new file mode 100644 index 0000000..d2de73a --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/attribute/service/AttributeValueService.java @@ -0,0 +1,83 @@ +package net.pinehaus.backend.attribute.service; + +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import net.pinehaus.backend.attribute.dto.AttributeValueDTO; +import net.pinehaus.backend.attribute.model.Attribute; +import net.pinehaus.backend.attribute.model.AttributeValue; +import net.pinehaus.backend.attribute.repository.AttributeValueRepository; +import net.pinehaus.backend.product.model.Product; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AttributeValueService { + + private final AttributeValueRepository attributeValueRepository; + private final AttributeService attributeService; + + public AttributeValue setProductAttribute(Product product, Attribute attribute, String value) { + AttributeValue attributeValue = new AttributeValue(); + + attributeValue.setProduct(product); + attributeValue.setAttribute(attribute); + attributeValue.setValue(value); + + return attributeValueRepository.save(attributeValue); + } + + public void removeProductAttribute(Product product, Attribute attribute) { + attributeValueRepository.deleteByAttributeAndProduct(attribute, product); + } + + @Transactional + public void compareAndUpdateAttributeValues(Product product, List updated) { + List current = attributeValueRepository.findByProductId(product.getId()); + + for (AttributeValue attributeValue : current) { + Optional matchingUpdatedAttribute = updated.stream() + .filter(updateAttribute -> + updateAttribute.getAttributeId() + == attributeValue.getAttribute() + .getId()) + .findFirst(); + + if (matchingUpdatedAttribute.isPresent()) { + // If value is different then update value + if (!matchingUpdatedAttribute.get().getValue().equals(attributeValue.getValue())) { + attributeValue.setValue(matchingUpdatedAttribute.get().getValue()); + attributeValueRepository.save(attributeValue); + } + } else { + // If attributeValueDTO is not found in updated, then remove it + attributeValueRepository.deleteByAttributeIdAndProductId( + attributeValue.getAttribute().getId(), product.getId()); + } + } + + for (AttributeValueDTO updatedAttribute : updated) { + Optional matchingCurrentAttribute = current.stream() + .filter(currentAttribute -> + currentAttribute.getAttribute() + .getId() + == updatedAttribute.getAttributeId()) + .findFirst(); + + if (matchingCurrentAttribute.isEmpty()) { + // If updatedAttribute is not found in current, then create it + Attribute attribute = attributeService.getById(updatedAttribute.getAttributeId()) + .orElseThrow(); + + AttributeValue newAttributeValue = new AttributeValue(); + newAttributeValue.setProduct(product); + newAttributeValue.setAttribute(attribute); + newAttributeValue.setValue(updatedAttribute.getValue()); + attributeValueRepository.save(newAttributeValue); + } + } + + } + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/authentication/service/TokenService.java b/backend/src/main/java/net/pinehaus/backend/authentication/service/TokenService.java index a035b89..cd22a2d 100644 --- a/backend/src/main/java/net/pinehaus/backend/authentication/service/TokenService.java +++ b/backend/src/main/java/net/pinehaus/backend/authentication/service/TokenService.java @@ -53,13 +53,13 @@ public boolean validateToken(String authToken) { return true; } catch (MalformedJwtException ex) { - log.error("Invalid JWT token"); + log.info("Invalid JWT token ({})", authToken); } catch (ExpiredJwtException ex) { - log.error("Expired JWT token"); + log.info("Expired JWT token ({})", authToken); } catch (UnsupportedJwtException ex) { - log.error("Unsupported JWT token"); + log.info("Unsupported JWT token ({})", authToken); } catch (IllegalArgumentException ex) { - log.error("JWT claims string is empty."); + log.info("JWT claims string is empty. ({})", authToken); } return false; diff --git a/backend/src/main/java/net/pinehaus/backend/category/controller/CategoryController.java b/backend/src/main/java/net/pinehaus/backend/category/controller/CategoryController.java new file mode 100644 index 0000000..6698c36 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/category/controller/CategoryController.java @@ -0,0 +1,34 @@ +package net.pinehaus.backend.category.controller; + +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import net.pinehaus.backend.category.model.Category; +import net.pinehaus.backend.category.service.CategoryService; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/categories") +@Tag(name = "Categories") +@RequiredArgsConstructor +public class CategoryController { + + private final CategoryService categoryService; + + @GetMapping("/{id}") + public Category getCategory(@PathVariable int id) { + return categoryService.getCategoryById(id).orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Category not found") + ); + } + + @GetMapping + public Iterable getAllCategories() { + return categoryService.getAllCategories(); + } + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/category/model/Category.java b/backend/src/main/java/net/pinehaus/backend/category/model/Category.java new file mode 100644 index 0000000..e57bbd3 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/category/model/Category.java @@ -0,0 +1,29 @@ +package net.pinehaus.backend.category.model; + +import com.fasterxml.jackson.annotation.JsonView; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import org.checkerframework.common.aliasing.qual.Unique; + +@Entity +@Getter +@Setter +@RequiredArgsConstructor +@JsonView(CategoryViews.Public.class) +public class Category { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int id; + + @Column(nullable = false) + @Unique + private String name; + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/category/model/CategoryViews.java b/backend/src/main/java/net/pinehaus/backend/category/model/CategoryViews.java new file mode 100644 index 0000000..17795c2 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/category/model/CategoryViews.java @@ -0,0 +1,9 @@ +package net.pinehaus.backend.category.model; + +public class CategoryViews { + + public interface Public { + + } + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/category/repository/CategoryRepository.java b/backend/src/main/java/net/pinehaus/backend/category/repository/CategoryRepository.java new file mode 100644 index 0000000..90c5a96 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/category/repository/CategoryRepository.java @@ -0,0 +1,13 @@ +package net.pinehaus.backend.category.repository; + +import java.util.Optional; +import net.pinehaus.backend.category.model.Category; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CategoryRepository extends JpaRepository { + + Optional findById(int id); + + Optional findByName(String name); + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/category/service/CategoryService.java b/backend/src/main/java/net/pinehaus/backend/category/service/CategoryService.java new file mode 100644 index 0000000..0a96217 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/category/service/CategoryService.java @@ -0,0 +1,28 @@ +package net.pinehaus.backend.category.service; + +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import net.pinehaus.backend.category.model.Category; +import net.pinehaus.backend.category.repository.CategoryRepository; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CategoryService { + + private final CategoryRepository categoryRepository; + + public Optional getCategoryById(int id) { + return categoryRepository.findById(id); + } + + public Optional getCategoryByName(String name) { + return categoryRepository.findByName(name); + } + + public List getAllCategories() { + return categoryRepository.findAll(); + } + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/image/controller/ImageController.java b/backend/src/main/java/net/pinehaus/backend/image/controller/ImageController.java new file mode 100644 index 0000000..97e7e63 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/image/controller/ImageController.java @@ -0,0 +1,51 @@ +package net.pinehaus.backend.image.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.HashMap; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.pinehaus.backend.image.service.ImageService; +import net.pinehaus.backend.util.ResponseUtilities; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@Tag(name = "Images") +@RequestMapping("/images") +@RequiredArgsConstructor +@PreAuthorize("hasAuthority('USER')") +@Slf4j +public class ImageController { + + private final ImageService imageService; + + @PostMapping + @Operation(summary = "Upload an image.", + description = "Upload an image file to the server.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Image uploaded successfully and in body returned is image name on CDN.")}) + public ResponseEntity> uploadImage( + @RequestParam("file") MultipartFile file) { + try { + String filename = imageService.saveImage(file, UUID.randomUUID().toString()); + + return new ResponseEntity<>(ResponseUtilities.createResponse("image", filename), + HttpStatus.OK); + } catch (Exception e) { + log.error("failed to upload image", e); + + return new ResponseEntity<>(ResponseUtilities.errorResponse(e.getMessage()), + HttpStatus.BAD_REQUEST); + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/image/service/ImageService.java b/backend/src/main/java/net/pinehaus/backend/image/service/ImageService.java new file mode 100644 index 0000000..ff4d8de --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/image/service/ImageService.java @@ -0,0 +1,72 @@ +package net.pinehaus.backend.image.service; + +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.List; +import javax.imageio.ImageIO; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Service +public class ImageService { + + @Value("${app.image.path}") + public String UPLOAD_DIR; + private static final long MAX_FILE_SIZE = 10485760; // 10MB + private static final List ALLOWED_MIME_TYPES = Arrays.asList("image/jpeg", "image/png"); + private static final List ALLOWED_EXTENSIONS = Arrays.asList(".jpg", ".jpeg", ".png"); + + public String saveImage(MultipartFile file, String name) throws IOException { + validateImage(file); + + String extension = getFileExtension(file); + + Path path = Paths.get(getUploadDir() + name + extension); + Files.write(path, file.getBytes()); + + return name + extension; + } + + + private String getUploadDir() { + if (!UPLOAD_DIR.endsWith(File.separator)) { + return UPLOAD_DIR + File.separator; + + } + + return UPLOAD_DIR; + } + + private String getFileExtension(MultipartFile file) { + String originalFilename = file.getOriginalFilename(); + assert originalFilename != null; + return originalFilename.substring(originalFilename.lastIndexOf(".")); + } + + private void validateImage(MultipartFile file) throws IOException { + if (file.getSize() > MAX_FILE_SIZE) { + throw new IOException("File size exceeds limit"); + } + + String mimeType = file.getContentType(); + if (mimeType == null || !ALLOWED_MIME_TYPES.contains(mimeType)) { + throw new IOException("Invalid MIME type"); + } + + String extension = getFileExtension(file); + if (!ALLOWED_EXTENSIONS.contains(extension)) { + throw new IOException("Invalid file extension"); + } + + BufferedImage image = ImageIO.read(file.getInputStream()); + if (image == null) { + throw new IOException("File is not an image"); + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/product/controller/ProductController.java b/backend/src/main/java/net/pinehaus/backend/product/controller/ProductController.java new file mode 100644 index 0000000..a7f71e0 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/product/controller/ProductController.java @@ -0,0 +1,152 @@ +package net.pinehaus.backend.product.controller; + +import com.fasterxml.jackson.annotation.JsonView; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import net.pinehaus.backend.product.dto.CreateProductDTO; +import net.pinehaus.backend.product.dto.ProductPageResponse; +import net.pinehaus.backend.product.dto.UpdateProductDTO; +import net.pinehaus.backend.product.model.Product; +import net.pinehaus.backend.product.model.ProductViews; +import net.pinehaus.backend.product.service.ProductService; +import net.pinehaus.backend.security.UserPrincipal; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/products") +@Tag(name = "Products") +@RequiredArgsConstructor +public class ProductController { + + private final static int PAGE_SIZE = 10; + private final static String DEFAULT_SORT = "asc"; + + private final ProductService productService; + + @GetMapping + @Operation(summary = "Get paginated products list.", + description = "Fetch paginated products list, optionally filtered by category and price range.") + @ApiResponses({@ApiResponse(responseCode = "200"), @ApiResponse(responseCode = "404")}) + @JsonView(ProductViews.Public.class) + public ProductPageResponse getProduct(@RequestParam(required = false) Optional page, + @RequestParam(required = false) Optional size, + @RequestParam(required = false) Optional sort, + @RequestParam(required = false) Optional categoryId, + @RequestParam(required = false) Optional min, + @RequestParam(required = false) Optional max) { + int _page = page.orElse(0); + int _size = size.orElse(PAGE_SIZE); + Sort.Direction _sort = + sort.orElse(DEFAULT_SORT).equals("asc") ? Sort.Direction.ASC : Sort.Direction.DESC; + + double _min = min.orElse(0d); + double _max = max.orElse(Double.MAX_VALUE); + + Page products; + + if (categoryId.isEmpty()) { + products = productService.getProductsByPriceBetween(_min, _max, _page, _size, _sort); + } else { + products = productService.getProductsByCategoryIdAndPriceBetween(categoryId.get(), _min, _max, + _page, + _size, _sort); + + } + + return new ProductPageResponse(products); + } + + @GetMapping("/{id}") + @JsonView(ProductViews.Public.class) + @Operation(summary = "Get a product by ID.", description = "Fetch product by ID, if it exists.") + @ApiResponses({@ApiResponse(responseCode = "200"), @ApiResponse(responseCode = "404")}) + public Product getProduct(@PathVariable int id) { + return productService.getProductById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, + "Product not found")); + } + + @PostMapping + @PreAuthorize("hasAuthority('USER')") + @JsonView(ProductViews.Public.class) + @Operation(summary = "Create a product.", description = "Create a new product.") + @ApiResponses({@ApiResponse(responseCode = "200"), @ApiResponse(responseCode = "409")}) + public Product createProduct(@RequestBody CreateProductDTO product, + @AuthenticationPrincipal UserPrincipal currentUser) { + if (productService.existsBySku(product.getSku())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Product already exists"); + } + + return productService.createProduct(product, currentUser.getUser()); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAuthority('USER')") + @JsonView(ProductViews.Public.class) + @Operation(summary = "Update a product.", description = "Update an existing product.") + @ApiResponses({@ApiResponse(responseCode = "200"), @ApiResponse(responseCode = "404"), + @ApiResponse(responseCode = "403")}) + public Product updateProduct(@PathVariable int id, + @AuthenticationPrincipal UserPrincipal currentUser, + @RequestBody UpdateProductDTO productUpdate) { + Optional existingProduct = productService.getProductById(id); + + if (existingProduct.isEmpty()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found"); + } + + Product product = existingProduct.get(); + + if (!product.getCreatedBy().getId().equals(currentUser.getId()) && !currentUser.isAdmin()) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, + "You are not allowed to update this product"); + } + + return productService.updateProduct(product, productUpdate); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasAuthority('USER')") + @Operation(summary = "Delete a product.", description = "Delete an existing product.") + @ApiResponses({@ApiResponse(responseCode = "200"), @ApiResponse(responseCode = "404"), + @ApiResponse(responseCode = "403")}) + public void deleteProduct(@PathVariable int id, + @AuthenticationPrincipal UserPrincipal currentUser) { + if (!productService.existsById(id)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found"); + } + + Optional existingProduct = productService.getProductById(id); + + if (existingProduct.isEmpty()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found"); + } + + if (!existingProduct.get().getCreatedBy().getId().equals(currentUser.getId()) + && !currentUser.isAdmin()) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, + "You are not allowed to delete this product"); + } + + productService.deleteProduct(id); + } + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/product/dto/CreateProductDTO.java b/backend/src/main/java/net/pinehaus/backend/product/dto/CreateProductDTO.java new file mode 100644 index 0000000..8c64b19 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/product/dto/CreateProductDTO.java @@ -0,0 +1,23 @@ +package net.pinehaus.backend.product.dto; + +import java.util.List; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import net.pinehaus.backend.attribute.dto.AttributeValueDTO; + +@Getter +@Setter +@RequiredArgsConstructor +public class CreateProductDTO { + + private String name; + private String description; + private String sku; + private int quantity; + private double price; + private String thumbnail; + private int categoryId; + private List attributes; + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/product/dto/ProductPageResponse.java b/backend/src/main/java/net/pinehaus/backend/product/dto/ProductPageResponse.java new file mode 100644 index 0000000..341aaa0 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/product/dto/ProductPageResponse.java @@ -0,0 +1,24 @@ +package net.pinehaus.backend.product.dto; + +import com.fasterxml.jackson.annotation.JsonView; +import java.util.List; +import lombok.Getter; +import lombok.Setter; +import net.pinehaus.backend.product.model.Product; +import net.pinehaus.backend.product.model.ProductViews; +import org.springframework.data.domain.Page; + +@Getter +@Setter +@JsonView(ProductViews.Public.class) +public class ProductPageResponse { + + private int totalPages; + private List products; + + public ProductPageResponse(Page page) { + this.totalPages = page.getTotalPages(); + this.products = page.getContent(); + } + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/product/dto/UpdateProductDTO.java b/backend/src/main/java/net/pinehaus/backend/product/dto/UpdateProductDTO.java new file mode 100644 index 0000000..496da70 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/product/dto/UpdateProductDTO.java @@ -0,0 +1,23 @@ +package net.pinehaus.backend.product.dto; + +import java.util.List; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import net.pinehaus.backend.attribute.dto.AttributeValueDTO; + +@Getter +@Setter +@RequiredArgsConstructor +public class UpdateProductDTO { + + private String name; + private String description; + private String sku; + private int quantity; + private double price; + private String thumbnail; + private int categoryId; + private List attributes; + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/product/model/Product.java b/backend/src/main/java/net/pinehaus/backend/product/model/Product.java new file mode 100644 index 0000000..4893e45 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/product/model/Product.java @@ -0,0 +1,66 @@ +package net.pinehaus.backend.product.model; + +import com.fasterxml.jackson.annotation.JsonView; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import java.util.List; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import net.pinehaus.backend.attribute.model.AttributeValue; +import net.pinehaus.backend.category.model.Category; +import net.pinehaus.backend.user.model.UserEntity; + +@Entity +@Getter +@Setter +@RequiredArgsConstructor +@JsonView(ProductViews.Public.class) +public class Product { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int id; + + @Column + private String slug; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String description; + + @Column(nullable = false, length = 10) + private String sku; + + @Column(nullable = false) + private int quantity; + + @Column(nullable = false) + private double price; + + @Column + @JsonView(ProductViews.Public.class) + private String thumbnail; + + @OneToMany(mappedBy = "product", cascade = CascadeType.ALL) + private List attributes; + + @JoinColumn(nullable = false) + @ManyToOne + @JsonView(ProductViews.Public.class) + private UserEntity createdBy; + + @JoinColumn + @ManyToOne + @JsonView(ProductViews.Public.class) + private Category category; +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/product/model/ProductViews.java b/backend/src/main/java/net/pinehaus/backend/product/model/ProductViews.java new file mode 100644 index 0000000..12a6fa8 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/product/model/ProductViews.java @@ -0,0 +1,14 @@ +package net.pinehaus.backend.product.model; + + +import net.pinehaus.backend.attribute.model.AttributeValueViews; +import net.pinehaus.backend.category.model.CategoryViews; +import net.pinehaus.backend.user.model.UserViews; + +public class ProductViews { + + public interface Public extends UserViews.Public, AttributeValueViews.Public, + CategoryViews.Public { + + } +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/product/repository/ProductRepository.java b/backend/src/main/java/net/pinehaus/backend/product/repository/ProductRepository.java new file mode 100644 index 0000000..cbf8b02 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/product/repository/ProductRepository.java @@ -0,0 +1,30 @@ +package net.pinehaus.backend.product.repository; + +import java.util.Optional; +import net.pinehaus.backend.product.model.Product; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductRepository extends JpaRepository { + + Optional findBySlug(String slug); + + Optional findById(int id); + + Optional findBySku(String sku); + + Page findAllByPriceBetween(double min, double max, Pageable pageable); + + Page findAllByCategoryId(int categoryId, Pageable pageable); + + Page findAllByCategoryIdAndPriceBetween(int categoryId, double min, double max, + Pageable pageable); + + boolean existsBySku(String sku); + + boolean existsBySlug(String slug); + + boolean existsById(int id); + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/product/service/ProductService.java b/backend/src/main/java/net/pinehaus/backend/product/service/ProductService.java new file mode 100644 index 0000000..9db6029 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/product/service/ProductService.java @@ -0,0 +1,154 @@ +package net.pinehaus.backend.product.service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import net.pinehaus.backend.attribute.dto.AttributeValueDTO; +import net.pinehaus.backend.attribute.model.Attribute; +import net.pinehaus.backend.attribute.service.AttributeService; +import net.pinehaus.backend.attribute.service.AttributeValueService; +import net.pinehaus.backend.category.model.Category; +import net.pinehaus.backend.category.service.CategoryService; +import net.pinehaus.backend.product.dto.CreateProductDTO; +import net.pinehaus.backend.product.dto.UpdateProductDTO; +import net.pinehaus.backend.product.model.Product; +import net.pinehaus.backend.product.repository.ProductRepository; +import net.pinehaus.backend.user.model.UserEntity; +import net.pinehaus.backend.util.Slugify; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ProductService { + + private final ProductRepository productRepository; + private final AttributeService attributeService; + private final AttributeValueService attributeValueService; + private final CategoryService categoryService; + + public Optional getProductBySku(String sku) { + return productRepository.findBySku(sku); + } + + public Optional getProductBySlug(String slug) { + return productRepository.findBySlug(slug); + } + + public Page getProductsByCategoryId(int categoryId, int page, int size, + Sort.Direction sortOrder) { + return productRepository.findAllByCategoryId(categoryId, + PageRequest.of(page, size, Sort.by(sortOrder, "price"))); + } + + public Page getProductsByCategoryIdAndPriceBetween(int categoryId, double min, + double max, int page, int size, Sort.Direction sortOrder) { + return productRepository.findAllByCategoryIdAndPriceBetween(categoryId, min, max, + PageRequest.of(page, size, Sort.by(sortOrder, "price"))); + } + + public Page getProductsByPriceBetween(double min, double max, int page, int size, + Sort.Direction sortOrder) { + return productRepository.findAllByPriceBetween(min, max, + PageRequest.of(page, size, Sort.by(sortOrder, "price"))); + } + + public Page getAllProducts(int page, int size, Sort.Direction sortOrder) { + return productRepository.findAll(PageRequest.of(page, size, Sort.by(sortOrder, "price"))); + } + + public Optional getProductById(int id) { + return productRepository.findById(id); + } + + public Product createProduct(CreateProductDTO product, UserEntity user) { + Product newProduct = new Product(); + + newProduct.setName(product.getName()); + newProduct.setDescription(product.getDescription()); + newProduct.setSku(product.getSku()); + newProduct.setQuantity(product.getQuantity()); + newProduct.setPrice(product.getPrice()); + if (product.getThumbnail() != null) { + newProduct.setThumbnail(product.getThumbnail().isBlank() ? null : product.getThumbnail()); + } + + newProduct.setCreatedBy(user); + newProduct.setAttributes(new ArrayList<>()); + newProduct.setSlug(Slugify.slugify(product.getName())); + + newProduct = productRepository.save(newProduct); + + /* Set attributes */ + List attributes = product.getAttributes(); + + for (AttributeValueDTO attribute : attributes) { + Optional attributeOptional = attributeService.getById(attribute.getAttributeId()); + + if (attributeOptional.isEmpty()) { + throw new IllegalArgumentException( + "Attribute with id " + attribute.getAttributeId() + " does not exist."); + } + + newProduct.getAttributes() + .add(attributeValueService.setProductAttribute(newProduct, attributeOptional.get(), + attribute.getValue())); + } + + /* Set category */ + Optional category = categoryService.getCategoryById(product.getCategoryId()); + + if (category.isEmpty()) { + throw new IllegalArgumentException( + "Category with id " + product.getCategoryId() + " does not exist."); + } + + newProduct.setCategory(category.get()); + newProduct = productRepository.save(newProduct); + + return newProduct; + } + + public boolean existsBySku(String sku) { + return productRepository.existsBySku(sku); + } + + public boolean existsBySlug(String slug) { + return productRepository.existsBySlug(slug); + } + + public boolean existsById(int id) { + return productRepository.existsById(id); + } + + public Product updateProduct(Product product, UpdateProductDTO payload) { + product.setName(payload.getName()); + product.setDescription(payload.getDescription()); + product.setSku(payload.getSku()); + product.setQuantity(payload.getQuantity()); + product.setPrice(payload.getPrice()); + product.setThumbnail(payload.getThumbnail()); + product.setSlug(Slugify.slugify(payload.getName())); + + /* Set category */ + Optional category = categoryService.getCategoryById(payload.getCategoryId()); + + if (category.isEmpty()) { + throw new IllegalArgumentException( + "Category with id " + payload.getCategoryId() + " does not exist."); + } + + product.setCategory(category.get()); + + attributeValueService.compareAndUpdateAttributeValues(product, payload.getAttributes()); + + return productRepository.save(product); + } + + public void deleteProduct(int id) { + productRepository.deleteById(id); + } +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/security/SecurityConfig.java b/backend/src/main/java/net/pinehaus/backend/security/SecurityConfig.java index 0f98a71..d634096 100644 --- a/backend/src/main/java/net/pinehaus/backend/security/SecurityConfig.java +++ b/backend/src/main/java/net/pinehaus/backend/security/SecurityConfig.java @@ -36,6 +36,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { config.setAllowCredentials(true); config.addAllowedOrigin(FRONTEND_URL); config.addAllowedOrigin("null"); + config.addAllowedOrigin("http://localhost:3000"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); @@ -46,7 +47,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authorizeHttpRequests(authorizeRequests -> authorizeRequests .requestMatchers("/swagger-ui/**", "/v3/**").permitAll() .requestMatchers("/login/**").permitAll() - .anyRequest().authenticated() + .anyRequest().permitAll() ) .addFilterBefore(pinehausAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); diff --git a/backend/src/main/java/net/pinehaus/backend/security/UserPrincipal.java b/backend/src/main/java/net/pinehaus/backend/security/UserPrincipal.java index f65a917..5779854 100644 --- a/backend/src/main/java/net/pinehaus/backend/security/UserPrincipal.java +++ b/backend/src/main/java/net/pinehaus/backend/security/UserPrincipal.java @@ -17,6 +17,7 @@ @AllArgsConstructor public class UserPrincipal implements UserDetails { + private UserEntity user; private UUID id; private String firstName; private String lastName; @@ -32,6 +33,7 @@ public class UserPrincipal implements UserDetails { public UserPrincipal(UserEntity user) { List authorities = AuthorityUtils.createAuthorityList(ADMIN, USER); + this.user = user; this.id = user.getId(); this.firstName = user.getFirstName(); this.lastName = user.getLastName(); diff --git a/backend/src/main/java/net/pinehaus/backend/user/controller/UserController.java b/backend/src/main/java/net/pinehaus/backend/user/controller/UserController.java index 74d005f..13b2288 100644 --- a/backend/src/main/java/net/pinehaus/backend/user/controller/UserController.java +++ b/backend/src/main/java/net/pinehaus/backend/user/controller/UserController.java @@ -17,6 +17,7 @@ import net.pinehaus.backend.util.ResponseUtilities; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -30,6 +31,7 @@ @RequiredArgsConstructor @Tag(name = "Users", description = "User API") @RequestMapping("/user") +@PreAuthorize("hasAuthority('USER')") public class UserController { private final UserService userService; diff --git a/backend/src/main/java/net/pinehaus/backend/user/model/UserEntity.java b/backend/src/main/java/net/pinehaus/backend/user/model/UserEntity.java index a9fc8e2..159ed3f 100644 --- a/backend/src/main/java/net/pinehaus/backend/user/model/UserEntity.java +++ b/backend/src/main/java/net/pinehaus/backend/user/model/UserEntity.java @@ -1,6 +1,7 @@ package net.pinehaus.backend.user.model; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonView; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -28,24 +29,31 @@ public class UserEntity { @Id @GeneratedValue(strategy = GenerationType.UUID) + @JsonView(UserViews.Public.class) private UUID id; @Column(nullable = false) + @JsonView(UserViews.Public.class) private String firstName; @Column(nullable = false) + @JsonView(UserViews.Public.class) private String lastName; + @JsonView(UserViews.Public.class) private String username; @Column(nullable = false) + @JsonView(UserViews.Public.class) private String email; @Column + @JsonView(UserViews.Public.class) private Date dateOfBirth; @Column + @JsonView(UserViews.Public.class) private String avatarUrl; @@ -58,15 +66,12 @@ public class UserEntity { * token. */ @Column - @JsonIgnore private String googleId; @Column(length = 2048) - @JsonIgnore private String sessionId; @Column - @JsonIgnore private Timestamp sessionExpiresAt; @CreationTimestamp diff --git a/backend/src/main/java/net/pinehaus/backend/user/model/UserViews.java b/backend/src/main/java/net/pinehaus/backend/user/model/UserViews.java new file mode 100644 index 0000000..c3f3ba6 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/user/model/UserViews.java @@ -0,0 +1,9 @@ +package net.pinehaus.backend.user.model; + +public class UserViews { + + public interface Public { + + } + +} \ No newline at end of file diff --git a/backend/src/main/java/net/pinehaus/backend/util/Slugify.java b/backend/src/main/java/net/pinehaus/backend/util/Slugify.java new file mode 100644 index 0000000..c710630 --- /dev/null +++ b/backend/src/main/java/net/pinehaus/backend/util/Slugify.java @@ -0,0 +1,19 @@ +package net.pinehaus.backend.util; + +import java.text.Normalizer; +import java.text.Normalizer.Form; +import java.util.Locale; +import java.util.regex.Pattern; + +public class Slugify { + + private static final Pattern NON_LATIN = Pattern.compile("[^\\w-]"); + private static final Pattern WHITESPACE = Pattern.compile("[\\s]"); + + public static String slugify(String input) { + String noWhitespace = WHITESPACE.matcher(input).replaceAll("-"); + String normalized = Normalizer.normalize(noWhitespace, Form.NFD); + String slug = NON_LATIN.matcher(normalized).replaceAll(""); + return slug.toLowerCase(Locale.ENGLISH); + } +} \ No newline at end of file diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index af21bb7..48c012f 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -16,6 +16,8 @@ app: frontend: url: ${FRONTEND_URL} cookieDomain: ${FRONTEND_COOKIE_DOMAIN} + image: + path: ${IMAGE_PATH} spring: datasource: diff --git a/docker/backend.prod.yml b/docker/backend.prod.yml index e4f690d..1911a5e 100644 --- a/docker/backend.prod.yml +++ b/docker/backend.prod.yml @@ -16,10 +16,13 @@ services: FRONTEND_URL: ${FRONTEND_URL} JWT_EXPIRATION_IN_MS: ${JWT_EXPIRATION_IN_MS} JWT_SECRET: ${JWT_SECRET} + IMAGE_PATH: /pinehaus/images depends_on: - pinehaus-database ports: - "4501:8080" + volumes: + - ${IMAGE_PATH}:/pinehaus/images pinehaus-database: image: postgres diff --git a/docker/backend.staging.yml b/docker/backend.staging.yml index c8fa11d..645fdfd 100644 --- a/docker/backend.staging.yml +++ b/docker/backend.staging.yml @@ -16,10 +16,13 @@ services: FRONTEND_URL: ${FRONTEND_URL} JWT_EXPIRATION_IN_MS: ${JWT_EXPIRATION_IN_MS} JWT_SECRET: ${JWT_SECRET} + IMAGE_PATH: /pinehaus/images depends_on: - pinehaus-database-staging ports: - "5501:8080" + volumes: + - ${IMAGE_PATH}:/pinehaus/images pinehaus-database-staging: image: postgres diff --git a/frontend/.dockerignore b/frontend/.dockerignore index f63abed..2124184 100644 --- a/frontend/.dockerignore +++ b/frontend/.dockerignore @@ -7,7 +7,6 @@ node_modules/ # FILES .dockerignore -.prettierrc *Dockerfile* jest.config.js -nodemon.json \ No newline at end of file +nodemon.json diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index aedfe11..824833e 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -16,12 +16,16 @@ "react/prop-types": "off", "react/react-in-jsx-scope": "off", "jsx-a11y/anchor-is-valid": "off", - "@typescript-eslint/no-unused-vars": ["error", { "ignoreRestSiblings": true, "varsIgnorePattern": "^_" }], + "@typescript-eslint/no-unused-vars": [ + "error", + { "ignoreRestSiblings": true, "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" } + ], "@typescript-eslint/ban-ts-comment": "off", "prettier/prettier": ["error", {}, { "usePrettierrc": true }], "@typescript-eslint/explicit-module-boundary-types": "off", "react/display-name": "off", "import/no-duplicates": ["warn"], + "react-hooks/exhaustive-deps": "off", "import/order": [ "warn", { diff --git a/frontend/next.config.js b/frontend/next.config.js index a98ed79..95adda4 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -10,8 +10,17 @@ const nextConfig = { protocol: 'https', hostname: 'images.pexels.com', }, + { + protocol: 'https', + hostname: 'cdn.pinehaus.net', + }, ], }, + experimental: { + serverActions: { + allowedOrigins: ['*.pinehaus.net', 'localhost:3000', 'localhost:8080'], + }, + }, } module.exports = nextConfig diff --git a/frontend/package.json b/frontend/package.json index 519ac22..2f0306d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,8 +12,12 @@ "@types/node": "20.4.8", "@types/react": "18.2.18", "@types/react-dom": "18.2.7", + "@typescript-eslint/eslint-plugin": "^7.8.0", "eslint": "8.46.0", "eslint-config-next": "13.4.12", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-import": "^2.29.1", + "eslint-plugin-prettier": "^5.1.3", "typescript": "5.1.6" }, "dependencies": { @@ -22,7 +26,8 @@ "@chakra-ui/react": "^2.8.2", "@emotion/react": "^11", "@emotion/styled": "^11", - "eslint-plugin-import": "^2.29.1", + "chakra-react-select": "^4.7.6", + "file-system-access": "^1.0.4", "formik": "^2.4.5", "framer-motion": "^6", "next": "14.1.0", diff --git a/frontend/public/images/banners/products-banner.jpg b/frontend/public/images/banners/products-banner.jpg new file mode 100644 index 0000000..9ddfa82 Binary files /dev/null and b/frontend/public/images/banners/products-banner.jpg differ diff --git a/frontend/src/actions/RevalidateProductAction/RevalidateProductAction.tsx b/frontend/src/actions/RevalidateProductAction/RevalidateProductAction.tsx new file mode 100644 index 0000000..4927341 --- /dev/null +++ b/frontend/src/actions/RevalidateProductAction/RevalidateProductAction.tsx @@ -0,0 +1,15 @@ +'use server' + +import { revalidatePath, revalidateTag } from 'next/cache' + +import { PRODUCT_FETCH_TAG } from 'api/Product/const' +import { productPageUrl } from 'utils/pages' + +export default async function RevalidateProductAction(productId: number, productSlug: string) { + const url = productPageUrl(productId, productSlug) + + revalidateTag(PRODUCT_FETCH_TAG) + revalidatePath(url) + + return url +} diff --git a/frontend/src/actions/RevalidateProductAction/index.ts b/frontend/src/actions/RevalidateProductAction/index.ts new file mode 100644 index 0000000..857dbd9 --- /dev/null +++ b/frontend/src/actions/RevalidateProductAction/index.ts @@ -0,0 +1 @@ +export { default } from './RevalidateProductAction' diff --git a/frontend/src/actions/index.ts b/frontend/src/actions/index.ts new file mode 100644 index 0000000..0bbc43a --- /dev/null +++ b/frontend/src/actions/index.ts @@ -0,0 +1 @@ +export { default as RevalidateProductAction } from './RevalidateProductAction' diff --git a/frontend/src/api/Attribute/interface.ts b/frontend/src/api/Attribute/interface.ts new file mode 100644 index 0000000..96ebaab --- /dev/null +++ b/frontend/src/api/Attribute/interface.ts @@ -0,0 +1,3 @@ +import { ProductAttribute } from 'model/Product' + +export type AttributesResponse = ProductAttribute[] diff --git a/frontend/src/api/Attribute/repository.ts b/frontend/src/api/Attribute/repository.ts new file mode 100644 index 0000000..9261db8 --- /dev/null +++ b/frontend/src/api/Attribute/repository.ts @@ -0,0 +1,6 @@ +import { getJson } from 'utils/api' + +import * as I from './interface' +import * as R from './routes' + +export const getAttributes = () => getJson(R.getAttributes()) diff --git a/frontend/src/api/Attribute/routes.ts b/frontend/src/api/Attribute/routes.ts new file mode 100644 index 0000000..12d7e8d --- /dev/null +++ b/frontend/src/api/Attribute/routes.ts @@ -0,0 +1,3 @@ +import { BASE_API_URL } from 'api/routes' + +export const getAttributes = () => `${BASE_API_URL}/attributes` diff --git a/frontend/src/api/Category/interface.ts b/frontend/src/api/Category/interface.ts new file mode 100644 index 0000000..749d354 --- /dev/null +++ b/frontend/src/api/Category/interface.ts @@ -0,0 +1,3 @@ +import { Category } from 'model/Category' + +export type CategoriesResponse = Category[] diff --git a/frontend/src/api/Category/repository.ts b/frontend/src/api/Category/repository.ts new file mode 100644 index 0000000..6a8abdd --- /dev/null +++ b/frontend/src/api/Category/repository.ts @@ -0,0 +1,6 @@ +import { getJson } from 'utils/api' + +import * as I from './interface' +import * as R from './routes' + +export const getCategories = () => getJson(R.getCategories()) diff --git a/frontend/src/api/Category/routes.ts b/frontend/src/api/Category/routes.ts new file mode 100644 index 0000000..85d5dbe --- /dev/null +++ b/frontend/src/api/Category/routes.ts @@ -0,0 +1,3 @@ +import { BASE_API_URL } from 'api/routes' + +export const getCategories = () => `${BASE_API_URL}/categories` diff --git a/frontend/src/api/Image/interface.ts b/frontend/src/api/Image/interface.ts new file mode 100644 index 0000000..dc151da --- /dev/null +++ b/frontend/src/api/Image/interface.ts @@ -0,0 +1,3 @@ +export interface ImageUploadResponse { + image: string +} diff --git a/frontend/src/api/Image/repository.ts b/frontend/src/api/Image/repository.ts new file mode 100644 index 0000000..3924512 --- /dev/null +++ b/frontend/src/api/Image/repository.ts @@ -0,0 +1,19 @@ +import { postJson } from 'utils/api' + +import * as I from './interface' +import * as R from './routes' + +/** + * Uploads an image to the CDN. + * + * @param file Bas64 encoded image. + */ +export const uploadImage = (file: File) => { + const formData = new FormData() + + formData.append('file', file) + + console.log(formData) + + return postJson(R.uploadImage(), formData) +} diff --git a/frontend/src/api/Image/routes.ts b/frontend/src/api/Image/routes.ts new file mode 100644 index 0000000..0aaf556 --- /dev/null +++ b/frontend/src/api/Image/routes.ts @@ -0,0 +1,3 @@ +import { BASE_API_URL } from 'api/routes' + +export const uploadImage = () => `${BASE_API_URL}/images` diff --git a/frontend/src/api/Product/const.ts b/frontend/src/api/Product/const.ts new file mode 100644 index 0000000..a75da75 --- /dev/null +++ b/frontend/src/api/Product/const.ts @@ -0,0 +1 @@ +export const PRODUCT_FETCH_TAG = 'PRODUCT_FETCH' diff --git a/frontend/src/api/Product/interface.ts b/frontend/src/api/Product/interface.ts new file mode 100644 index 0000000..6990149 --- /dev/null +++ b/frontend/src/api/Product/interface.ts @@ -0,0 +1,22 @@ +import { ProductFormValues } from 'components/Product' +import { Product } from 'model/Product' + +export interface ProductListFilters { + page?: number + sort?: 'asc' | 'desc' + size?: number + categoryId?: number + min?: number + max?: number +} + +export interface ProductListResponse { + products: Product[] + totalPages: number +} + +export interface GetProductResponse extends Product {} + +export interface UpdateProductPayload extends Omit { + thumbnail?: string | null +} diff --git a/frontend/src/api/Product/repository.ts b/frontend/src/api/Product/repository.ts new file mode 100644 index 0000000..dccbd56 --- /dev/null +++ b/frontend/src/api/Product/repository.ts @@ -0,0 +1,18 @@ +import { Product } from 'model/Product' +import { deleteJson, getJson, postJson, putJson } from 'utils/api' + +import { PRODUCT_FETCH_TAG } from './const' +import * as I from './interface' +import * as R from './routes' + +export const getProducts = (query: I.ProductListFilters) => getJson(R.getProductList(query)) + +export const getProduct = (id: number) => + getJson(R.getProduct(id), { next: { tags: [PRODUCT_FETCH_TAG] } }) + +export const updateProduct = (id: number, payload: I.UpdateProductPayload) => + putJson(R.updateProduct(id), payload) + +export const createProduct = (payload: I.UpdateProductPayload) => postJson(R.createProduct(), payload) + +export const deleteProduct = (id: number) => deleteJson(R.deleteProduct(id)) diff --git a/frontend/src/api/Product/routes.ts b/frontend/src/api/Product/routes.ts new file mode 100644 index 0000000..7b2b2b9 --- /dev/null +++ b/frontend/src/api/Product/routes.ts @@ -0,0 +1,14 @@ +import { BASE_API_URL } from 'api/routes' +import { query } from 'utils/api' + +import { ProductListFilters } from './interface' + +export const getProductList = (queryObject: ProductListFilters) => `${BASE_API_URL}/products${query(queryObject)}` + +export const getProduct = (id: number) => `${BASE_API_URL}/products/${id}` + +export const updateProduct = (id: number) => `${BASE_API_URL}/products/${id}` + +export const createProduct = () => `${BASE_API_URL}/products` + +export const deleteProduct = (id: number) => `${BASE_API_URL}/products/${id}` diff --git a/frontend/src/api/User/repository.ts b/frontend/src/api/User/repository.ts index f340ef3..4981063 100644 --- a/frontend/src/api/User/repository.ts +++ b/frontend/src/api/User/repository.ts @@ -1,6 +1,6 @@ import { putJson } from 'utils/api' -import { UpdateUserPayload } from './interface' +import { UpdateUserPayload } from './interface' import * as R from './routes' export const updateUser = (id: string, payload: UpdateUserPayload) => putJson(R.updateUser(id), payload) diff --git a/frontend/src/api/repository.ts b/frontend/src/api/repository.ts index 99ce374..4ac45cd 100644 --- a/frontend/src/api/repository.ts +++ b/frontend/src/api/repository.ts @@ -1,6 +1,6 @@ import { getJson, postJson } from 'utils/api' -import * as R from './routes' +import * as R from './routes' import { IGetMeResponse } from './interface' export const getMe = () => getJson(R.getMe()) diff --git a/frontend/src/api/routes.ts b/frontend/src/api/routes.ts index 568853f..6c043d8 100644 --- a/frontend/src/api/routes.ts +++ b/frontend/src/api/routes.ts @@ -1,8 +1,19 @@ +import { isFullUrl } from 'utils/url' + export const BASE_API_URL = process.env.NEXT_PUBLIC_API_URL export const CORE_URL = process.env.NEXT_PUBLIC_PINEHAUS_URL +export const CDN_URL = 'https://cdn.pinehaus.net' export const googleLoginRedirectRoute = () => `${BASE_API_URL}/login/oauth2/code/google` export const logout = () => `${BASE_API_URL}/login/logout` export const getMe = () => `${BASE_API_URL}/user/me` + +export const image = (image: string) => { + if (!image) return undefined + + if (isFullUrl(image)) return image + + return `${CDN_URL}/${image}` +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index ca45fd6..6757e91 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,4 +1,5 @@ import { Box } from '@chakra-ui/react' + import 'styles/global/globals.css' import Navigation from 'components/Navigation' import Footer from 'components/Footer' diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index ea3161f..f58791c 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -1,16 +1,42 @@ 'use client' import { Box, Flex, Text } from '@chakra-ui/react' -import { googleLoginRedirectRoute } from 'api/routes' import { useUser } from 'hooks/authentication' import { redirect } from 'next/navigation' import Script from 'next/script' +import { useEffect, useRef } from 'react' + +import { googleLoginRedirectRoute } from 'api/routes' const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID +declare global { + interface Window { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + google: any + } +} + export default function LoginPage() { const { isUserLoading, isAuthenticated } = useUser() + const g_sso = useRef(null) + + useEffect(() => { + if (typeof window === 'undefined') return + if (!window.google) return + + window.google.accounts.id.renderButton(g_sso.current, { + theme: 'outline', + size: 'large', + type: 'standard', + text: 'signin_with', + shape: 'rectangular', + logo_alignment: 'left', + width: '400', + }) + }, []) + if (isUserLoading) { return null } @@ -57,6 +83,7 @@ export default function LoginPage() { data-size="large" data-logo_alignment="left" data-width="400" + ref={g_sso} > diff --git a/frontend/src/app/products/[id]/[slug]/edit/page.tsx b/frontend/src/app/products/[id]/[slug]/edit/page.tsx new file mode 100644 index 0000000..daacf1b --- /dev/null +++ b/frontend/src/app/products/[id]/[slug]/edit/page.tsx @@ -0,0 +1,16 @@ +import { getProduct } from 'api/Product/repository' +import ProductEditPage from 'modules/Products/Edit' + +interface Props { + params: { + id: string + } +} + +export default async function Page({ params }: Props) { + const productId = Number(params.id) + + const product = await getProduct(productId) + + return +} diff --git a/frontend/src/app/products/[id]/[slug]/page.tsx b/frontend/src/app/products/[id]/[slug]/page.tsx new file mode 100644 index 0000000..489cac3 --- /dev/null +++ b/frontend/src/app/products/[id]/[slug]/page.tsx @@ -0,0 +1,16 @@ +import { getProduct } from 'api/Product/repository' +import { ProductPage } from 'modules/Products/Product' + +interface Props { + params: { + id: string + } +} + +export default async function Page({ params }: Props) { + const productId = Number(params.id) + + const product = await getProduct(productId) + + return +} diff --git a/frontend/src/app/products/create/page.tsx b/frontend/src/app/products/create/page.tsx new file mode 100644 index 0000000..3204fbf --- /dev/null +++ b/frontend/src/app/products/create/page.tsx @@ -0,0 +1,9 @@ +import ProductCreatePage from 'modules/Products/Create' + +export default function Page() { + return ( + <> + + + ) +} diff --git a/frontend/src/app/products/error.tsx b/frontend/src/app/products/error.tsx new file mode 100644 index 0000000..6bf30c2 --- /dev/null +++ b/frontend/src/app/products/error.tsx @@ -0,0 +1,5 @@ +'use client' + +export default function Error() { + return <>Something went wrong :( +} diff --git a/frontend/src/app/products/page.tsx b/frontend/src/app/products/page.tsx new file mode 100644 index 0000000..7229da4 --- /dev/null +++ b/frontend/src/app/products/page.tsx @@ -0,0 +1,50 @@ +import { ChevronRight } from '@carbon/icons-react' +import { Box, Breadcrumb, BreadcrumbItem, BreadcrumbLink, Button, Flex, Text } from '@chakra-ui/react' +import Image from 'next/image' +import Link from 'next/link' + +import { Products } from 'modules/Products/List' +import { productCreateUrl } from 'utils/pages' + +export default function Page() { + return ( + <> + + Product page banner + + + + Explore our products + + + } mb={2}> + + Pinehaus + + + + Products + + + + + + + + + + ) +} diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx index 20142d2..937e2aa 100644 --- a/frontend/src/app/profile/page.tsx +++ b/frontend/src/app/profile/page.tsx @@ -2,26 +2,17 @@ import { ChevronRight, Information } from '@carbon/icons-react' import { Text } from '@chakra-ui/layout' -import { - Box, - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - Button, - Container, - Divider, - Flex, - useToast, -} from '@chakra-ui/react' -import { updateUser } from 'api/User/repository' -import { useErrorToast, useSavingToast, useSuccessToast } from 'components/Toast' +import { Box, Breadcrumb, BreadcrumbItem, BreadcrumbLink, Container, Divider, Flex, useToast } from '@chakra-ui/react' import { FormikHelpers } from 'formik' import { useUser } from 'hooks/authentication' -import { UserForm } from 'modules/Profile' -import { UserFormValues } from 'modules/Profile/UserForm/interface' import Image from 'next/image' import { redirect } from 'next/navigation' +import { updateUser } from 'api/User/repository' +import { useErrorToast, useSavingToast, useSuccessToast } from 'components/Toast' +import { UserForm } from 'modules/Profile' +import { UserFormValues } from 'modules/Profile/UserForm/interface' + export default function ProfilePage() { const { user, isUserLoading, isAuthenticated } = useUser() @@ -57,12 +48,12 @@ export default function ProfilePage() { Profile page banner diff --git a/frontend/src/components/Footer/Footer.tsx b/frontend/src/components/Footer/Footer.tsx index 55bbaf9..9f60ef1 100644 --- a/frontend/src/components/Footer/Footer.tsx +++ b/frontend/src/components/Footer/Footer.tsx @@ -1,4 +1,5 @@ import { Box, Flex, Text } from '@chakra-ui/react' + import Link from 'components/Link' export default function Footer() { diff --git a/frontend/src/components/Navigation/Navigation.tsx b/frontend/src/components/Navigation/Navigation.tsx index 6968326..725b667 100644 --- a/frontend/src/components/Navigation/Navigation.tsx +++ b/frontend/src/components/Navigation/Navigation.tsx @@ -1,7 +1,7 @@ 'use client' import { useMediaQuery } from '@chakra-ui/react' -import { useUser } from 'hooks/authentication' + import { DesktopNavigation, MobileNavigation } from './components' export default function Navigation() { diff --git a/frontend/src/components/Navigation/components/DesktopNavigation/DesktopNavigation.tsx b/frontend/src/components/Navigation/components/DesktopNavigation/DesktopNavigation.tsx index 2f93f43..239208e 100644 --- a/frontend/src/components/Navigation/components/DesktopNavigation/DesktopNavigation.tsx +++ b/frontend/src/components/Navigation/components/DesktopNavigation/DesktopNavigation.tsx @@ -1,9 +1,11 @@ +import { Logout, UserAvatar } from '@carbon/icons-react' import { Box, Button, Flex, Grid, Text } from '@chakra-ui/react' -import NextLink from 'next/link' -import Link from 'components/Link' import { useUser } from 'hooks/authentication' -import { Logout, UserAvatar } from '@carbon/icons-react' +import NextLink from 'next/link' + import { logout } from 'api/repository' +import Link from 'components/Link' +import { USER_AVATAR_SIZE } from 'components/Navigation/const' export default function DesktopNavigation() { const { user, isAuthenticated } = useUser() @@ -53,17 +55,18 @@ export default function DesktopNavigation() { {user?.avatarUrl ? ( + {/* eslint-disable-next-line @next/next/no-img-element */} User avatar ) : ( - + )} diff --git a/frontend/src/components/Navigation/components/MobileNavigation/MobileNavigation.tsx b/frontend/src/components/Navigation/components/MobileNavigation/MobileNavigation.tsx index f7048c9..73baf4e 100644 --- a/frontend/src/components/Navigation/components/MobileNavigation/MobileNavigation.tsx +++ b/frontend/src/components/Navigation/components/MobileNavigation/MobileNavigation.tsx @@ -1,10 +1,12 @@ -import React, { useEffect, useRef, useState } from 'react' -import NextLink from 'next/link' -import { Box, Button, Flex, Text, useDisclosure, Collapse } from '@chakra-ui/react' -import Link from 'components/Link' -import { useUser } from 'hooks/authentication' import { Logout, Menu, UserAvatar } from '@carbon/icons-react' +import { Box, Button, Collapse, Flex, Text, useDisclosure } from '@chakra-ui/react' +import { useUser } from 'hooks/authentication' +import NextLink from 'next/link' +import { useEffect, useRef } from 'react' + import { logout } from 'api/repository' +import Link from 'components/Link' +import { USER_AVATAR_SIZE } from 'components/Navigation/const' const MobileDropdownNav = () => { const { isOpen, onToggle, onClose } = useDisclosure() @@ -23,7 +25,7 @@ const MobileDropdownNav = () => { return () => { document.removeEventListener('mousedown', handleClickOutside) } - }, [containerRef, onToggle]) + }, [containerRef, onClose, onToggle]) const handleLogout = () => { logout() @@ -52,12 +54,13 @@ const MobileDropdownNav = () => { {user?.avatarUrl ? ( + {/* eslint-disable-next-line @next/next/no-img-element */} User avatar diff --git a/frontend/src/components/Navigation/const.ts b/frontend/src/components/Navigation/const.ts new file mode 100644 index 0000000..0c4bfe7 --- /dev/null +++ b/frontend/src/components/Navigation/const.ts @@ -0,0 +1 @@ +export const USER_AVATAR_SIZE = 36 diff --git a/frontend/src/components/NumberInput/NumberInput.tsx b/frontend/src/components/NumberInput/NumberInput.tsx new file mode 100644 index 0000000..a381fc0 --- /dev/null +++ b/frontend/src/components/NumberInput/NumberInput.tsx @@ -0,0 +1,28 @@ +import { Button, HStack, Input, InputProps, UseNumberInputProps, useNumberInput } from '@chakra-ui/react' + +interface Props { + numberInputProps?: UseNumberInputProps + inputProps?: InputProps +} +export default function NumberInput({ numberInputProps, inputProps }: Props) { + const { getInputProps, getIncrementButtonProps, getDecrementButtonProps } = useNumberInput({ + step: 1, + defaultValue: 1, + min: 1, + max: 6, + precision: 0, + ...numberInputProps, + }) + + const inc = getIncrementButtonProps() + const dec = getDecrementButtonProps() + const input = getInputProps() + + return ( + + + + + + ) +} diff --git a/frontend/src/components/NumberInput/index.ts b/frontend/src/components/NumberInput/index.ts new file mode 100644 index 0000000..f493643 --- /dev/null +++ b/frontend/src/components/NumberInput/index.ts @@ -0,0 +1 @@ +export { default } from './NumberInput' diff --git a/frontend/src/components/Pagination/Pagination.tsx b/frontend/src/components/Pagination/Pagination.tsx new file mode 100644 index 0000000..17bfb74 --- /dev/null +++ b/frontend/src/components/Pagination/Pagination.tsx @@ -0,0 +1,47 @@ +import { ChevronLeft, ChevronRight } from '@carbon/icons-react' +import { Button, Flex, FlexProps } from '@chakra-ui/react' + +import { getPages } from './utils' + +interface Props extends FlexProps { + page: number + totalPages: number + onPageChange: (page: number) => void +} + +export default function Pagination({ onPageChange, page, totalPages, ...flexProps }: Props) { + const goToFirstPage = () => onPageChange(1) + const goToPreviousPage = () => onPageChange(page - 1) + const goToNextPage = () => onPageChange(page + 1) + const goToLastPage = () => onPageChange(totalPages) + + const pages = getPages(page, totalPages) + + return ( + + + + + + {pages.map(p => ( + + ))} + + + + + + ) +} diff --git a/frontend/src/components/Pagination/index.ts b/frontend/src/components/Pagination/index.ts new file mode 100644 index 0000000..40ac52f --- /dev/null +++ b/frontend/src/components/Pagination/index.ts @@ -0,0 +1 @@ +export { default } from './Pagination' diff --git a/frontend/src/components/Pagination/utils.ts b/frontend/src/components/Pagination/utils.ts new file mode 100644 index 0000000..10a7565 --- /dev/null +++ b/frontend/src/components/Pagination/utils.ts @@ -0,0 +1,20 @@ +// Must be odd number to keep current page in the middle +const NUMBER_OF_PAGES_SHOWN = 5 + +export const getPages = (page: number, totalPages: number) => { + const aroundCurrentPage = (NUMBER_OF_PAGES_SHOWN - 1) / 2 + + if (totalPages <= NUMBER_OF_PAGES_SHOWN) { + return Array.from({ length: totalPages }, (_, i) => i + 1) + } + + if (page <= aroundCurrentPage) { + return Array.from({ length: NUMBER_OF_PAGES_SHOWN }, (_, i) => i + 1) + } + + if (page >= totalPages - aroundCurrentPage) { + return Array.from({ length: NUMBER_OF_PAGES_SHOWN }, (_, i) => totalPages - NUMBER_OF_PAGES_SHOWN + i + 1) + } + + return Array.from({ length: NUMBER_OF_PAGES_SHOWN }, (_, i) => page - aroundCurrentPage + i) +} diff --git a/frontend/src/components/Product/ProductForm/ProductForm.tsx b/frontend/src/components/Product/ProductForm/ProductForm.tsx new file mode 100644 index 0000000..63a004f --- /dev/null +++ b/frontend/src/components/Product/ProductForm/ProductForm.tsx @@ -0,0 +1,268 @@ +import { CloudUpload, Delete, Save } from '@carbon/icons-react' +import { + Box, + Button, + Flex, + FlexProps, + FormControl, + FormErrorMessage, + FormLabel, + Input, + NumberInput, + NumberInputField, + Select, + Spinner, + Text, + Textarea, +} from '@chakra-ui/react' +import { Formik, FormikHelpers, useFormikContext } from 'formik' +import Image from 'next/image' +import { useEffect, useState } from 'react' + +import { getCategories } from 'api/Category/repository' +import { image } from 'api/routes' +import { Category } from 'model/Category' +import { Product } from 'model/Product' +import { toBase64 } from 'utils/base64' + +import { Attributes } from './components' +import { ProductFormValues } from './interface' +import { ProductFormValidationSchema, mapProductToUserFormValues } from './utils' + +interface Props extends FlexProps { + handleSubmit: (values: ProductFormValues, formikHelpers: FormikHelpers) => void + product?: Product +} + +const DEAFULT_ACCEPT_IMAGE = { + 'image/*': ['.png', '.jpeg', '.jpg'], +} + +function ProductForm({ isNew }: { isNew: boolean }) { + const [categories, setCategories] = useState() + + const { + values, + errors, + status, + touched, + setFieldValue, + handleBlur, + submitForm, + isSubmitting, + isValidating, + isValid, + dirty, + } = useFormikContext() + + const { thumbnail } = status as Product + + const [encodedUploadedImage, setEncodedUploadedImage] = useState(image(thumbnail)) + + useEffect(() => { + getCategories().then(setCategories).catch(console.error) + }, []) + + const handleFirstNameChange = (field: string) => (e: React.ChangeEvent) => + setFieldValue(field, e.target.value) + + const handleNumberChange = (field: string) => (value: string) => setFieldValue(field, value) + + const handleCategoryChange = (e: React.ChangeEvent) => { + const categoryId = +e.target.value + setFieldValue('categoryId', categoryId === 0 ? undefined : categoryId) + } + + const handleDescriptionChange = (e: React.ChangeEvent) => + setFieldValue('description', e.target.value) + + const showFilePicker = () => { + if (!('showOpenFilePicker' in window)) { + return + } + + window + .showOpenFilePicker({ + types: [ + { + description: 'Product image', + accept: DEAFULT_ACCEPT_IMAGE, + }, + ], + }) + .then(([fileHandle]) => fileHandle.getFile()) + .then(file => { + setFieldValue('thumbnail', file) + + return toBase64(file) + }) + .then(setEncodedUploadedImage) + // If user cancels operation exception is thrown + .catch(e => console.error('File picker operation canceled, probably', e)) + } + + const handleRemoveImage = () => { + setFieldValue('thumbnail', null) + setEncodedUploadedImage(undefined) + } + + if (!categories) { + return ( + + + + ) + } + + return ( + <> + + {isNew ? 'Create product listing' : 'Update product'} + + + + + {/* Product form fields */} + + + Product name + + {errors.name} + + + + SKU + + {errors.sku} + + + + + + + Price + + + + {errors.price} + + + + Quantity in stock + + + + {errors.quantity} + + + + + + + + + + Category + + {errors.categoryId} + + + + + + + + + Description +