diff --git a/.github/workflows/complete-workflow.yml b/.github/workflows/complete-workflow.yml index 3b6efec..c93da42 100644 --- a/.github/workflows/complete-workflow.yml +++ b/.github/workflows/complete-workflow.yml @@ -113,9 +113,11 @@ jobs: DB_PASSWORD: password run: | cd ../backend/pcmod - ./mvnw spring-boot:run > /dev/null 2>&1 < /dev/null & + sudo -E ./mvnw spring-boot:run > /dev/null 2>&1 < /dev/null & - npx wait-on https-get://localhost:8443/api/v1/components/ + npx wait-on https-get://localhost:443/api/v1/components/ + + sudo chown -R $USER:$USER target/ cd ../../frontend npm run test:coverage @@ -186,10 +188,12 @@ jobs: npm run dev > /dev/null 2>&1 < /dev/null & cd ../backend/pcmod - ./mvnw spring-boot:run > /dev/null 2>&1 < /dev/null & + sudo -E ./mvnw spring-boot:run > /dev/null 2>&1 < /dev/null & - npx wait-on http-get://localhost:5173 https-get://localhost:8443/api/v1/components/ + npx wait-on http-get://localhost:5173 https-get://localhost:443/api/v1/components/ + sudo chown -R $USER:$USER target/ + ./mvnw test -Dgroups="client-system" # SonarCloud Analysis diff --git a/backend/pcmod/pom.xml b/backend/pcmod/pom.xml index 7828959..ac5a7e0 100644 --- a/backend/pcmod/pom.xml +++ b/backend/pcmod/pom.xml @@ -224,7 +224,7 @@ - http://127.0.0.1:8443/v3/api-docs.yaml + http://127.0.0.1:443/v3/api-docs.yaml api-docs.yaml ${project.basedir}/docs/api diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/controller/ComponentsRestController.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/controller/ComponentsRestController.java index 12c1eda..288a807 100644 --- a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/controller/ComponentsRestController.java +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/controller/ComponentsRestController.java @@ -4,6 +4,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.List; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -23,4 +25,9 @@ public Page findAll(Pageable pageable) { return componentService.findAll(pageable).map(component -> componentService.toDTO(component)); } + @GetMapping("/recent") + public List findTop3MoreRecent() { + return componentService.findTop3MoreRecent().stream().map(componentService::toDTO).toList(); + } + } diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/repository/ComponentsRepository.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/repository/ComponentsRepository.java index e121eb7..ce0eab4 100644 --- a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/repository/ComponentsRepository.java +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/repository/ComponentsRepository.java @@ -1,9 +1,13 @@ package es.codeurjcstudents.pcmod.repository; +import java.util.List; + import org.springframework.data.jpa.repository.JpaRepository; import es.codeurjcstudents.pcmod.model.Component; public interface ComponentsRepository extends JpaRepository { + List findTop3ByOrderByIdDesc(); + } \ No newline at end of file diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/service/ComponentsService.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/service/ComponentsService.java index 5bd2971..87c4772 100644 --- a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/service/ComponentsService.java +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/service/ComponentsService.java @@ -1,5 +1,7 @@ package es.codeurjcstudents.pcmod.service; +import java.util.List; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -29,4 +31,8 @@ public Page findAll(Pageable pageable) { public ComponentDTO toDTO(Component component) { return componentMapper.toDTO(component); } + + public List findTop3MoreRecent() { + return componentRepository.findTop3ByOrderByIdDesc(); + } } diff --git a/backend/pcmod/src/main/resources/application.properties b/backend/pcmod/src/main/resources/application.properties index 7633fb9..72145b3 100644 --- a/backend/pcmod/src/main/resources/application.properties +++ b/backend/pcmod/src/main/resources/application.properties @@ -1,6 +1,6 @@ spring.application.name=pcmod -server.port=8443 +server.port=443 server.ssl.enabled=true server.ssl.key-store=classpath:keystore.jks server.ssl.key-store-password=password diff --git a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/api/HomePageSystemTests.java b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/api/HomePageSystemTests.java new file mode 100644 index 0000000..04073ad --- /dev/null +++ b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/api/HomePageSystemTests.java @@ -0,0 +1,42 @@ +package es.codeurjcstudents.pcmod.e2e.api; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.*; + +@Tag("server-system") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class HomePageSystemTests { + + @LocalServerPort + private int port; + + @BeforeEach + public void setup() { + RestAssured.port = port; + RestAssured.baseURI = "https://localhost"; + RestAssured.basePath = "/api/v1"; + RestAssured.useRelaxedHTTPSValidation(); + } + + @Test + public void getComponents() { + given().header("Content-Type", "application/json") + .when().get("/components/recent") + .then().statusCode(200).contentType(ContentType.JSON) + // Global checks + .body("size()", equalTo(3)) + // Specific checks + .body("[0].name", equalTo("Kingston FURY Beast")) + .body("[1].name", equalTo("Seagate BarraCuda 3.5")) + .body("[2].name", equalTo("AMD Radeon RX 9060 XT DUAL WHITE")); + } + +} diff --git a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/ComponentsSystemTests.java b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/ComponentsSystemTests.java index b59c0b9..e1be7e2 100644 --- a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/ComponentsSystemTests.java +++ b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/ComponentsSystemTests.java @@ -47,7 +47,7 @@ public void teardown() { @Test public void loadComponentsTest() throws InterruptedException { - driver.get("http://localhost:5173/"); + driver.get("http://localhost:5173/components"); wait.until(ExpectedConditions.presenceOfElementLocated(By.id("name-1"))); String componentName = driver.findElement(By.id("name-1")).getText(); diff --git a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/HomePageSystemTests.java b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/HomePageSystemTests.java new file mode 100644 index 0000000..719f35b --- /dev/null +++ b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/HomePageSystemTests.java @@ -0,0 +1,87 @@ +package es.codeurjcstudents.pcmod.e2e.ui; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.chrome.ChromeDriver; +import org.openqa.selenium.chrome.ChromeOptions; +import org.openqa.selenium.support.ui.ExpectedConditions; +import org.openqa.selenium.support.ui.WebDriverWait; + +@Tag("client-system") +public class HomePageSystemTests { + + private WebDriver driver; + + private WebDriverWait wait; + + @BeforeEach + public void setupTest() { + ChromeOptions options = new ChromeOptions(); + options.addArguments("--incognito"); + options.addArguments("--disable-notifications"); + options.addArguments("--disable-features=PasswordLeakDetection"); + options.addArguments("--headless"); + options.addArguments("--no-sandbox"); + options.addArguments("--disable-dev-shm-usage"); + + driver = new ChromeDriver(options); + wait = new WebDriverWait(driver, Duration.ofSeconds(10)); + } + + @AfterEach + public void teardown() { + if (driver != null) { + driver.quit(); + } + } + + @Test + public void loadHomePage() throws InterruptedException { + + driver.get("http://localhost:5173/"); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("welcome"))); + String welcomeMessage = driver.findElement(By.id("welcome")).getText(); + assertThat(welcomeMessage).isEqualTo("Bienvenido a PCMod"); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("logo"))); + String logoMessage = driver.findElement(By.id("logo")).getAttribute("alt"); + assertThat(logoMessage).isEqualTo("PCMod Logo"); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("component-9"))); + String componentName9 = driver.findElement(By.id("component-9")).getText(); + assertThat(componentName9).isEqualTo("AMD Radeon RX 9060 XT DUAL WHITE"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("component-10"))); + String componentName10 = driver.findElement(By.id("component-10")).getText(); + assertThat(componentName10).isEqualTo("Seagate BarraCuda 3.5"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("component-11"))); + String componentName11 = driver.findElement(By.id("component-11")).getText(); + assertThat(componentName11).isEqualTo("Kingston FURY Beast"); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.name("componentsButton"))); + assertThat(driver.findElements(By.name("componentsButton"))).isNotEmpty(); + + } + + @Test + public void navigateToComponentsPage() throws InterruptedException { + + driver.get("http://localhost:5173/"); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.name("componentsButton"))); + WebElement componentsButton = driver.findElement(By.name("componentsButton")); + componentsButton.click(); + + wait.until(ExpectedConditions.urlToBe("http://localhost:5173/components")); + + } +} diff --git a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/integration/ComponentsIntegrationTests.java b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/integration/ComponentsIntegrationTests.java index d08625d..2e27abd 100644 --- a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/integration/ComponentsIntegrationTests.java +++ b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/integration/ComponentsIntegrationTests.java @@ -29,7 +29,7 @@ public class ComponentsIntegrationTests { @Container - private static final MySQLContainer mysqlContainer = new MySQLContainer<>("mysql:8.0") + private static final MySQLContainer mysqlContainer = new MySQLContainer<>("mysql:8.4") .withDatabaseName("TestDB") .withUsername("TestDBUser") .withPassword("TestDBPassword"); diff --git a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/integration/HomePageIntegrationTests.java b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/integration/HomePageIntegrationTests.java new file mode 100644 index 0000000..d36a6a0 --- /dev/null +++ b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/integration/HomePageIntegrationTests.java @@ -0,0 +1,98 @@ +package es.codeurjcstudents.pcmod.integration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import es.codeurjcstudents.pcmod.enums.ComponentType; +import es.codeurjcstudents.pcmod.model.Component; +import es.codeurjcstudents.pcmod.repository.ComponentsRepository; +import es.codeurjcstudents.pcmod.service.ComponentsService; + +@Tag("server-integration") +@SpringBootTest +@Testcontainers +public class HomePageIntegrationTests { + + @Container + private static final MySQLContainer mysqlContainer = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("TestDB") + .withUsername("TestDBUser") + .withPassword("TestDBPassword"); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", + () -> mysqlContainer.getJdbcUrl() + "?useSSL=false&allowPublicKeyRetrieval=true"); + + registry.add("spring.datasource.username", mysqlContainer::getUsername); + registry.add("spring.datasource.password", mysqlContainer::getPassword); + registry.add("spring.datasource.driver-class-name", mysqlContainer::getDriverClassName); + } + + @Autowired + private ComponentsService componentsService; + + @Autowired + private ComponentsRepository componentsRepository; + + @BeforeEach + void setUp() { + componentsRepository.deleteAll(); + componentsRepository.save(new Component("AMD Ryzen 7 7800X3D", + "8 núcleos y 16 hilos ideales para gaming\r\n" + // + "96 MB de caché L3 3D V-Cache, baja latencia\r\n" + // + "Compatible con memorias DDR5, doble canal\r\n" + // + "Frecuencia base 4.2 GHz y turbo hasta 5.0 GHz", + ComponentType.CPU, "AMD", BigDecimal.valueOf(350.00), 5)); + componentsRepository.save(new Component("NVIDIA GeForce RTX 5060 WINDFORCE MAX OC", + "Ray Tracing y DLSS 4 para gaming fluido\r\n" + // + "8GB GDDR7 y arquitectura Blackwell\r\n" + // + "WINDFORCE 2X: refrigeración eficiente y silenciosa\r\n" + // + "DisplayPort 2.1 y HDMI 2.1b hasta 8K\r\n" + // + "Tensor Cores 5ª gen y Reflex 2 para IA avanzada", + ComponentType.GPU, "Gigabyte", BigDecimal.valueOf(359.85), 2)); + componentsRepository.save(new Component("Kingston NV3", + "Capacidad: 1 TB\r\n" + // + "Factor de forma de disco SSD: M.2\r\n" + // + "Interfaz: PCI Express 4.0\r\n" + // + "NVMe: Si\r\n" + // + "Tipo de memoria: 3D NAND", + ComponentType.STORAGE, "Kingston", BigDecimal.valueOf(149.95), 15)); + } + + @Test + void loadRecentComponents() { + + List componentList = componentsService.findTop3MoreRecent(); + + assertEquals(3, componentList.size()); + assertEquals("Kingston NV3", componentList.get(0).getName()); + assertEquals("NVIDIA GeForce RTX 5060 WINDFORCE MAX OC", componentList.get(1).getName()); + assertEquals("AMD Ryzen 7 7800X3D", componentList.get(2).getName()); + } + + @Test + void loadWithoutRecentComponents() { + + componentsRepository.deleteAll(); + + List componentList = componentsService.findTop3MoreRecent(); + + assertTrue(componentList.isEmpty()); + } +} \ No newline at end of file diff --git a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/unit/HomePageUnitTests.java b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/unit/HomePageUnitTests.java new file mode 100644 index 0000000..bd39071 --- /dev/null +++ b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/unit/HomePageUnitTests.java @@ -0,0 +1,61 @@ +package es.codeurjcstudents.pcmod.unit; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.List; + +import es.codeurjcstudents.pcmod.enums.ComponentType; +import es.codeurjcstudents.pcmod.model.Component; +import es.codeurjcstudents.pcmod.repository.ComponentsRepository; +import es.codeurjcstudents.pcmod.service.ComponentsService; + +@Tag("server-unit") +public class HomePageUnitTests { + + @Test + public void testRecentComponentsLoad() { + + ComponentsRepository componentsRepository = mock(ComponentsRepository.class); + ComponentsService componentsService = new ComponentsService(componentsRepository); + + Component component1 = new Component("AMD Ryzen 7 7800X3D", + "8 núcleos y 16 hilos ideales para gaming\r\n" + // + "96 MB de caché L3 3D V-Cache, baja latencia\r\n" + // + "Compatible con memorias DDR5, doble canal\r\n" + // + "Frecuencia base 4.2 GHz y turbo hasta 5.0 GHz", + ComponentType.CPU, "AMD", BigDecimal.valueOf(350.00), 5); + + Component component2 = new Component("NVIDIA GeForce RTX 5060 WINDFORCE MAX OC", + "Ray Tracing y DLSS 4 para gaming fluido\r\n" + // + "8GB GDDR7 y arquitectura Blackwell\r\n" + // + "WINDFORCE 2X: refrigeración eficiente y silenciosa\r\n" + // + "DisplayPort 2.1 y HDMI 2.1b hasta 8K\r\n" + // + "Tensor Cores 5ª gen y Reflex 2 para IA avanzada", + ComponentType.GPU, "Gigabyte", BigDecimal.valueOf(359.85), 2); + + Component component3 = new Component("Kingston NV3", + "Capacidad: 1 TB\r\n" + // + "Factor de forma de disco SSD: M.2\r\n" + // + "Interfaz: PCI Express 4.0\r\n" + // + "NVMe: Si\r\n" + // + "Tipo de memoria: 3D NAND", + ComponentType.STORAGE, "Kingston", BigDecimal.valueOf(149.95), 15); + + List componentList = Arrays.asList(component1, component2, component3); + when(componentsRepository.findTop3ByOrderByIdDesc()).thenReturn(componentList); + + List actualRecentComponents = componentsService.findTop3MoreRecent(); + + assertEquals(componentList, actualRecentComponents); + verify(componentsRepository).findTop3ByOrderByIdDesc(); + + } + +} diff --git a/docs/DEVELOPMENT-GUIDE.md b/docs/DEVELOPMENT-GUIDE.md index 7d40520..597ce7b 100644 --- a/docs/DEVELOPMENT-GUIDE.md +++ b/docs/DEVELOPMENT-GUIDE.md @@ -257,7 +257,7 @@ This section describes the tools used to develop the PCMod application. PCMod is divided into three processes: * Client (Frontend): A React SPA available on port 5173 that communicates with the REST API through HTTP requests. It allows users to interact with the application. -* Server (Backend): A Spring Boot REST API available on port 8443 that communicates with the frontend via HTTP and with the database via TCP. It contains the business logic and operate with the database. +* Server (Backend): A Spring Boot REST API available on port 443 that communicates with the frontend via HTTP and with the database via TCP. It contains the business logic and operate with the database. * Database: A MySQL database available on port 3306, that communicates with the backend via TCP. It is a relational database that ensures data integrity and persistence. Communication between the frontend and backend is carried out using JSON format. @@ -559,7 +559,7 @@ cd backend/pcmod mvn spring-boot:run ``` -Open a browser and navigate to https://localhost:8443/. +Open a browser and navigate to https://localhost:443/. #### 🛑 Stop application @@ -638,7 +638,7 @@ cd frontend npm install cd ../backend/pcmod ./mvnw spring-boot:run > /dev/null 2>&1 < /dev/null & -npx wait-on https-get://localhost:8443/api/v1/components/ +npx wait-on https-get://localhost:443/api/v1/components/ cd ../../frontend npm run test ``` @@ -649,7 +649,7 @@ cd frontend npm run dev > /dev/null 2>&1 < /dev/null & cd ../backend/pcmod ./mvnw spring-boot:run > /dev/null 2>&1 < /dev/null & -npx wait-on http-get://localhost:5173 https-get://localhost:8443/api/v1/components/ +npx wait-on http-get://localhost:5173 https-get://localhost:443/api/v1/components/ ./mvnw test -Dgroups="client-system" ``` diff --git a/docs/api/api-docs.html b/docs/api/api-docs.html index 4b1cb13..eee544f 100644 --- a/docs/api/api-docs.html +++ b/docs/api/api-docs.html @@ -1056,7 +1056,7 @@

Usage and SDK Samples

curl -X GET \
  -H "Accept: */*" \
- "http://127.0.0.1:8443/api/v1/components/?pageable="
+ "http://127.0.0.1:443/api/v1/components/?pageable="
 
diff --git a/docs/api/api-docs.yaml b/docs/api/api-docs.yaml index 656e9a3..6557f3a 100644 --- a/docs/api/api-docs.yaml +++ b/docs/api/api-docs.yaml @@ -3,7 +3,7 @@ info: title: OpenAPI definition version: v0 servers: -- url: http://127.0.0.1:8443 +- url: http://127.0.0.1:443 description: Generated server url paths: /api/v1/components/: diff --git a/docs/api/postman/PCMod.postman_collection.json b/docs/api/postman/PCMod.postman_collection.json index 0e0c67b..9191642 100644 --- a/docs/api/postman/PCMod.postman_collection.json +++ b/docs/api/postman/PCMod.postman_collection.json @@ -16,12 +16,12 @@ "method": "GET", "header": [], "url": { - "raw": "https://localhost:8443/api/v1/components/?page=0&size=10", + "raw": "https://localhost:443/api/v1/components/?page=0&size=10", "protocol": "https", "host": [ "localhost" ], - "port": "8443", + "port": "443", "path": [ "api", "v1", diff --git a/frontend/app/app.css b/frontend/app/app.css index 12f820e..81ec193 100644 --- a/frontend/app/app.css +++ b/frontend/app/app.css @@ -1,17 +1,349 @@ -@theme { - --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif, - "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; -} - html, body { - @apply bg-white dark:bg-gray-950; + margin: 0; + padding: 0; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +:root { + --font-sans: 'Courier New', Courier, monospace, sans-serif; + --color-ink: #1b2b1d; + --color-muted: #66564c; + --color-surface: #ffffff; + --color-page: #fff8f2; + --color-line: #f0d8c5; + --color-brand: #c45116; + --color-brand-dark: #8f350d; + --color-principal: #f95e00; + --color-footer-muted: #f5d9c5; + --color-section: #dfdcdc; +} + +/* Header */ + +.header { + font-family: var(--font-sans); + position: sticky; + top: 0; + z-index: 10; + min-height: 76px; + border-bottom: 1px solid var(--color-line); + background: var(--color-principal); + box-shadow: 0 3px 16px rgb(84 37 20 / 10%); + backdrop-filter: blur(12px); +} + +.header-content { + display: flex; + align-items: center; + flex-wrap: nowrap; + gap: 1.5rem; + max-width: 1180px; + margin-left: 0; + padding: 0.5rem 1rem; +} + +.header .logo img { + display: block; + width: min(250px, 52vw); + height: auto; + max-height: 64px; + object-fit: contain; +} + +.header-content>a:not(.logo) { + position: relative; + padding: 0.65rem 0.1rem; + color: #ffffff; + font-size: 0.96rem; + font-weight: 700; + text-decoration: none; + transition: color 160ms ease; +} + +.header-content>a:not(.logo)::after { + position: absolute; + right: 0; + bottom: 0.3rem; + left: 0; + height: 3px; + border-radius: 999px; + background: var(--color-brand); + content: ""; + transform: scaleX(0); + transform-origin: center; + transition: transform 160ms ease; +} + +.header-content>a:not(.logo):hover, +.header-content>a:not(.logo):focus-visible { + color: #f0d8c5; +} + +.header-content>a:not(.logo):hover::after, +.header-content>a:not(.logo):focus-visible::after { + transform: scaleX(1); +} + +/* Footer */ + +.footer { + font-family: var(--font-sans); + flex-shrink: 0; + margin-top: auto; + background: var(--color-principal); + color: #ffffff; +} + +.social-container { + display: flex; + align-items: center; + justify-content: center; + gap: 2.5rem; + max-width: 1000px; + margin: 0 auto; + padding: 1.5rem; +} + +.social-link { + display: inline-flex; + align-items: center; + gap: 0.55rem; + color: #ffffff; + font-size: 1.2rem; + text-decoration: none; + transition: color 160ms ease; +} + +.social-link:hover, +.social-link:focus-visible { + color: #f0d8c5; +} + +.footer-bottom { + border-top: 1px solid rgb(245 217 197 / 24%); +} + +.footer-bottom-content { + max-width: 1180px; + margin: 0 auto; + padding: 0.5rem; + color: var(--color-principal-muted); + font-size: 1.0rem; + text-align: center; +} + +.sitename { + color: #ffffff; + font-weight: 1000; + letter-spacing: 0.1em; +} + +/* General */ + +.main, +.home-page { + background-color: var(--color-page); + min-height: 100vh; + padding: 3rem 1rem; + font-family: var(--font-sans); +} + +.main .container, +.home-page .container { + max-width: 1000px; + margin: 0 auto; +} + +.main section.row, +.home-page section.row { + display: flex; + align-items: center; + gap: 2.5rem; + margin-bottom: 4rem; + background: var(--color-section); + padding: 2rem; + border-radius: 8px; + border: 1px solid #e5e7eb; +} + +.main section.row .col, +.home-page section.row .col { + flex: 1; +} - @media (prefers-color-scheme: dark) { - color-scheme: dark; +.main section.row img, +.home-page section.row img { + width: 100%; + height: auto; + object-fit: cover; + border-radius: 4px; +} + +.main h1, +.home-page h1 { + font-size: 1.8rem; + font-weight: 600; + margin-bottom: 1rem; + text-align: center; +} + +.main p, +.home-page p { + font-size: 1rem; + line-height: 1.4; + color: #4b5563; + margin-bottom: 1rem; + text-align: justify; +} + +.recentComponents-section { + text-align: center; + margin-top: 3rem; +} + +.recentComponents-section h2 { + font-size: 1.6rem; + font-weight: 500; + margin-bottom: 2.5rem; + color: #111827; +} + +.row-recentComponents { + display: flex; + flex-wrap: wrap; + gap: 1.5rem; + justify-content: center; + margin-bottom: 2.5rem; +} + +.component-card-col { + flex: 1 1 calc(33.333% - 1.5rem); + min-width: 260px; + max-width: 320px; +} + +.pcmod-button { + background-color: var(--color-principal); + color: #ffffff; + border: none !important; + padding: 0.75rem 2rem !important; + font-size: 1.1rem !important; + border-radius: 8px !important; + font-weight: 500 !important; + transition: background-color 0.2s ease, transform 0.1s ease; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + font-family: var(--font-sans); +} + +.pcmod-button:hover { + background-color: var(--color-brand-dark); + transform: translateY(-1px); + font-family: var(--font-sans); +} + +@media (max-width: 768px) { + + .main section.row, + .home-page section.row { + flex-direction: column; + padding: 1.5rem; } + + .main section.row .col, + .home-page section.row .col { + flex: 1 1 100%; + max-width: 100%; + } + + .component-card-col { + flex: 1 1 100%; + max-width: 100%; + } +} + +/* Component Card */ + +.component-card-link { + display: flex; + flex-direction: column; + text-decoration: none !important; + color: inherit; + background-color: #ffffff; + border-radius: 8px; + overflow: hidden; + border: 1px solid #e5e7eb; + transition: transform 0.2s ease, box-shadow 0.2s ease; + height: 100%; +} + +.component-card-link:hover { + transform: translateY(-4px); + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + color: inherit; } -.component-description { - white-space: pre-line; +.component-card-link .bg-secondary { + height: 200px !important; + background-color: #e5e7eb !important; + color: #9ca3af !important; + position: relative; + border-bottom: 1px solid #e5e7eb; + align-content: center; +} + +.component-card-link .bg-secondary svg { + width: 48px; + height: 48px; +} + +.component-information { + padding: 1rem; + text-align: center; +} + +.component-information h3 { + font-size: 1.2rem; + font-weight: 500; + color: #111827; + margin-bottom: 0.5rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.component-information h4 { + font-size: 0.95rem; + font-weight: 400; + color: #6b7280; + margin: 0; +} + +/* Spinner */ +.spinner-overlay { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + background-color: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(2px); +} + +.spinner { + width: 40px; + height: 40px; + border: 4px solid rgba(255, 165, 0, 0.25); + border-top-color: var(--color-principal); + border-radius: 50%; + animation: spinner-rotation 800ms linear infinite; +} + +@keyframes spinner-rotation { + 100% { + transform: rotate(360deg); + } } \ No newline at end of file diff --git a/frontend/app/components/componentCard.tsx b/frontend/app/components/componentCard.tsx new file mode 100644 index 0000000..fa39411 --- /dev/null +++ b/frontend/app/components/componentCard.tsx @@ -0,0 +1,34 @@ +import { Link } from "react-router"; +import { Image as ImageIcon } from "react-bootstrap-icons"; +import type ComponentDTO from "~/dtos/ComponentDTO"; + +interface ComponentCardProps { + component: ComponentDTO; +} + +export default function ComponentCard({ component }: ComponentCardProps) { + return ( +
+ +
+ +
+ {/* + component.image ? ( + {component.name} + ) + */} + +
+

{component.name}

+

{component.type} | {component.price} €

+
+ +
+ ); +} \ No newline at end of file diff --git a/frontend/app/components/footer.tsx b/frontend/app/components/footer.tsx new file mode 100644 index 0000000..722c7cd --- /dev/null +++ b/frontend/app/components/footer.tsx @@ -0,0 +1,31 @@ +import { Envelope, Youtube } from 'react-bootstrap-icons'; + +export default function Footer() { + return <> + + +} \ No newline at end of file diff --git a/frontend/app/components/header.tsx b/frontend/app/components/header.tsx new file mode 100644 index 0000000..3b3c103 --- /dev/null +++ b/frontend/app/components/header.tsx @@ -0,0 +1,20 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router"; +import { Image } from "react-bootstrap"; + +export default function Header() { + + return <> + + ; +} \ No newline at end of file diff --git a/frontend/app/routes.ts b/frontend/app/routes.ts index aa25061..18ee9cd 100644 --- a/frontend/app/routes.ts +++ b/frontend/app/routes.ts @@ -3,5 +3,6 @@ import { type RouteConfig, layout, route } from "@react-router/dev/routes"; export default [ layout("routes/home.tsx", [ route("/", "routes/index.tsx"), + route("/components", "routes/components.tsx"), ]), ] satisfies RouteConfig; diff --git a/frontend/app/routes/components.tsx b/frontend/app/routes/components.tsx new file mode 100644 index 0000000..bc3089e --- /dev/null +++ b/frontend/app/routes/components.tsx @@ -0,0 +1,81 @@ +import { useEffect, useState } from "react"; +import type { Route } from "./+types/components"; +import { getComponents } from "~/services/components-service"; + +export async function clientLoader({ request }: Route.ClientLoaderArgs) { + + const url = new URL(request.url); + const page = Number(url.searchParams.get("page") ?? 0); + const result = await getComponents(page); + + return { items: result.items, hasNext: result.hasNext }; +} + +export default function Components({ loaderData }: Route.ComponentProps) { + const [components, setComponents] = useState(loaderData.items); + const [hasNext, setHasNext] = useState(loaderData.hasNext); + const [currentPage, setCurrentPage] = useState(1); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [loadMoreError, setLoadMoreError] = useState(false); + + useEffect(() => { + setComponents(loaderData.items); + setHasNext(loaderData.hasNext); + setCurrentPage(1); + setIsLoadingMore(false); + setLoadMoreError(false); + }, [loaderData]); + + const handleLoadMore = async () => { + if (isLoadingMore || !hasNext) { + return; + } + + setIsLoadingMore(true); + setLoadMoreError(false); + + try { + const result = await getComponents(currentPage); + setComponents((previousComponents) => [...previousComponents, ...result.items]); + setHasNext(result.hasNext); + + if (result.hasNext) { + setCurrentPage((previousPage) => previousPage + 1); + } + } catch { + setLoadMoreError(true); + } finally { + setIsLoadingMore(false); + } + }; + + + return ( +
+
+

Componentes:

+
    + {components.length > 0 && ( + components.map((component) => ( +
  • +

    {component.name}

    +

    Marca: {component.brand} | Tipo: {component.type} | Precio: {component.price} € | Stock: {component.stock} ud.

    +

    {component.description}

    +
    +
  • + )) + )} +
+
+ {components.length > 0 && hasNext && ( + + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/app/routes/home.tsx b/frontend/app/routes/home.tsx index ea24b61..1f2b81d 100644 --- a/frontend/app/routes/home.tsx +++ b/frontend/app/routes/home.tsx @@ -1,11 +1,24 @@ -import { Outlet } from "react-router"; +import { Outlet, useNavigation } from "react-router"; +import Footer from "~/components/footer"; +import Header from "~/components/header"; export default function Home() { + + const navigation = useNavigation(); + const isLoading = navigation.state === "loading"; + return ( <> + {isLoading && ( +
+
+
+ )} +
+