diff --git a/backend/src/main/java/com/threeriversbank/controller/ApplicationController.java b/backend/src/main/java/com/threeriversbank/controller/ApplicationController.java new file mode 100644 index 0000000..66ca23c --- /dev/null +++ b/backend/src/main/java/com/threeriversbank/controller/ApplicationController.java @@ -0,0 +1,55 @@ +package com.threeriversbank.controller; + +import com.threeriversbank.model.dto.ApplicationRequestDto; +import com.threeriversbank.model.dto.ApplicationResponseDto; +import com.threeriversbank.service.ApplicationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/applications") +@RequiredArgsConstructor +@Slf4j +@Tag(name = "Applications", description = "Three Rivers Bank Credit Card Application API") +public class ApplicationController { + + private final ApplicationService applicationService; + + @PostMapping + @Operation(summary = "Submit credit card application", description = "Submits a new business credit card application") + public ResponseEntity submitApplication( + @Valid @RequestBody ApplicationRequestDto request, + HttpServletRequest httpRequest) { + log.info("POST /api/applications - card ID: {}", request.getCreditCardId()); + String ipAddress = getClientIp(httpRequest); + try { + ApplicationResponseDto response = applicationService.submitApplication(request, ipAddress); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } catch (IllegalStateException e) { + log.warn("Rate limit or business rule violation: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) + .body(Map.of("error", e.getMessage())); + } catch (IllegalArgumentException e) { + log.warn("Validation error: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", e.getMessage())); + } + } + + private String getClientIp(HttpServletRequest request) { + String xForwardedFor = request.getHeader("X-Forwarded-For"); + if (xForwardedFor != null && !xForwardedFor.isEmpty()) { + return xForwardedFor.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } +} diff --git a/backend/src/main/java/com/threeriversbank/model/dto/ApplicationRequestDto.java b/backend/src/main/java/com/threeriversbank/model/dto/ApplicationRequestDto.java new file mode 100644 index 0000000..b25f898 --- /dev/null +++ b/backend/src/main/java/com/threeriversbank/model/dto/ApplicationRequestDto.java @@ -0,0 +1,139 @@ +package com.threeriversbank.model.dto; + +import jakarta.validation.constraints.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ApplicationRequestDto { + + @NotNull(message = "Credit card ID is required") + private Long creditCardId; + + // Business Information + @NotBlank(message = "Business legal name is required") + @Size(max = 200, message = "Business legal name must not exceed 200 characters") + private String businessLegalName; + + @Size(max = 200, message = "DBA name must not exceed 200 characters") + private String dbaName; + + @NotBlank(message = "Business structure is required") + private String businessStructure; + + @NotBlank(message = "Tax ID is required") + @Pattern(regexp = "^\\d{9}$", message = "Tax ID must be 9 digits") + private String taxId; + + @NotBlank(message = "Industry is required") + private String industry; + + @NotNull(message = "Years in business is required") + @Min(value = 0, message = "Years in business must be non-negative") + private Integer yearsInBusiness; + + @NotNull(message = "Number of employees is required") + @Min(value = 1, message = "Number of employees must be at least 1") + private Integer numberOfEmployees; + + @NotBlank(message = "Annual revenue is required") + private String annualRevenue; + + @NotBlank(message = "Business street address is required") + private String businessStreet; + + @NotBlank(message = "Business city is required") + private String businessCity; + + @NotBlank(message = "Business state is required") + @Size(min = 2, max = 2, message = "Business state must be 2 characters") + private String businessState; + + @NotBlank(message = "Business ZIP is required") + @Pattern(regexp = "^\\d{5}(-\\d{4})?$", message = "Business ZIP must be a valid ZIP code") + private String businessZip; + + @NotBlank(message = "Business phone is required") + @Pattern(regexp = "^[\\d\\-\\+\\(\\)\\s]{7,20}$", message = "Business phone must be a valid phone number") + private String businessPhone; + + @Pattern(regexp = "^$|^https?://[\\w.-]+(?:\\.[\\w.-]+)+[/\\w.-]*$", message = "Business website must be a valid URL") + private String businessWebsite; + + // Personal Information + @NotBlank(message = "First name is required") + @Size(max = 100, message = "First name must not exceed 100 characters") + private String ownerFirstName; + + @NotBlank(message = "Last name is required") + @Size(max = 100, message = "Last name must not exceed 100 characters") + private String ownerLastName; + + @NotBlank(message = "Date of birth is required") + private String dateOfBirth; // ISO format: YYYY-MM-DD, validated in service + + @NotBlank(message = "SSN is required") + @Pattern(regexp = "^\\d{9}$", message = "SSN must be 9 digits") + private String ssn; + + @NotBlank(message = "Email is required") + @Email(message = "Email must be a valid email address") + private String ownerEmail; + + @NotBlank(message = "Home street address is required") + private String ownerStreet; + + @NotBlank(message = "Home city is required") + private String ownerCity; + + @NotBlank(message = "Home state is required") + @Size(min = 2, max = 2, message = "Home state must be 2 characters") + private String ownerState; + + @NotBlank(message = "Home ZIP is required") + @Pattern(regexp = "^\\d{5}(-\\d{4})?$", message = "Home ZIP must be a valid ZIP code") + private String ownerZip; + + @NotBlank(message = "Mobile phone is required") + @Pattern(regexp = "^[\\d\\-\\+\\(\\)\\s]{7,20}$", message = "Mobile phone must be a valid phone number") + private String ownerPhone; + + @NotNull(message = "Ownership percentage is required") + @Min(value = 1, message = "Ownership percentage must be at least 1") + @Max(value = 100, message = "Ownership percentage must not exceed 100") + private Integer ownershipPercentage; + + @NotBlank(message = "Title/Position is required") + @Size(max = 100, message = "Title must not exceed 100 characters") + private String ownerTitle; + + @NotNull(message = "Annual personal income is required") + @DecimalMin(value = "0.00", message = "Annual income must be non-negative") + private BigDecimal annualPersonalIncome; + + // Card Preferences + @NotBlank(message = "Requested credit limit is required") + private String requestedCreditLimit; + + @Min(value = 0, message = "Number of employee cards must be non-negative") + @Max(value = 50, message = "Number of employee cards must not exceed 50") + private Integer numberOfEmployeeCards; + + // Terms + @NotNull(message = "Agreement to terms is required") + @AssertTrue(message = "You must agree to the terms and conditions") + private Boolean agreedToTerms; + + @NotNull(message = "Consent to credit check is required") + @AssertTrue(message = "You must consent to a credit check") + private Boolean consentToCreditCheck; + + @NotBlank(message = "Electronic signature is required") + @Size(max = 200, message = "Electronic signature must not exceed 200 characters") + private String electronicSignature; +} diff --git a/backend/src/main/java/com/threeriversbank/model/dto/ApplicationResponseDto.java b/backend/src/main/java/com/threeriversbank/model/dto/ApplicationResponseDto.java new file mode 100644 index 0000000..7c974a9 --- /dev/null +++ b/backend/src/main/java/com/threeriversbank/model/dto/ApplicationResponseDto.java @@ -0,0 +1,26 @@ +package com.threeriversbank.model.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ApplicationResponseDto { + + private String applicationId; + private Long id; + private String status; + private LocalDateTime submittedAt; + private String cardName; + private String ownerFirstName; + private String ownerLastName; + private String ownerEmail; + private String message; + private String expectedDecisionTimeline; +} diff --git a/backend/src/main/java/com/threeriversbank/model/entity/BusinessCreditCardApplication.java b/backend/src/main/java/com/threeriversbank/model/entity/BusinessCreditCardApplication.java new file mode 100644 index 0000000..d3bc89e --- /dev/null +++ b/backend/src/main/java/com/threeriversbank/model/entity/BusinessCreditCardApplication.java @@ -0,0 +1,139 @@ +package com.threeriversbank.model.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Table(name = "business_credit_card_application") +@Data +@NoArgsConstructor +@AllArgsConstructor +public class BusinessCreditCardApplication { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 50) + private String applicationId; + + // Card reference + @Column(nullable = false) + private Long creditCardId; + + // Business Information + @Column(nullable = false, length = 200) + private String businessLegalName; + + @Column(length = 200) + private String dbaName; + + @Column(nullable = false, length = 50) + private String businessStructure; + + @Column(nullable = false, length = 100) + private String taxId; // stored encrypted + + @Column(nullable = false, length = 100) + private String industry; + + @Column(nullable = false) + private Integer yearsInBusiness; + + @Column(nullable = false) + private Integer numberOfEmployees; + + @Column(nullable = false, length = 50) + private String annualRevenue; + + @Column(nullable = false, length = 200) + private String businessStreet; + + @Column(nullable = false, length = 100) + private String businessCity; + + @Column(nullable = false, length = 2) + private String businessState; + + @Column(nullable = false, length = 10) + private String businessZip; + + @Column(nullable = false, length = 20) + private String businessPhone; + + @Column(length = 255) + private String businessWebsite; + + // Personal Information + @Column(nullable = false, length = 100) + private String ownerFirstName; + + @Column(nullable = false, length = 100) + private String ownerLastName; + + @Column(nullable = false) + private LocalDate dateOfBirth; + + @Column(nullable = false, length = 100) + private String ssn; // stored encrypted + + @Column(nullable = false, length = 255) + private String ownerEmail; + + @Column(nullable = false, length = 200) + private String ownerStreet; + + @Column(nullable = false, length = 100) + private String ownerCity; + + @Column(nullable = false, length = 2) + private String ownerState; + + @Column(nullable = false, length = 10) + private String ownerZip; + + @Column(nullable = false, length = 20) + private String ownerPhone; + + @Column(nullable = false) + private Integer ownershipPercentage; + + @Column(nullable = false, length = 100) + private String ownerTitle; + + @Column(nullable = false, precision = 15, scale = 2) + private BigDecimal annualPersonalIncome; + + // Card Preferences + @Column(nullable = false, length = 20) + private String requestedCreditLimit; + + @Column + private Integer numberOfEmployeeCards; + + // Terms + @Column(nullable = false) + private Boolean agreedToTerms; + + @Column(nullable = false) + private Boolean consentToCreditCheck; + + @Column(nullable = false, length = 200) + private String electronicSignature; + + // Status + @Column(nullable = false, length = 20) + private String status; // Pending, Under Review, Approved, Denied + + @Column(nullable = false) + private LocalDateTime submittedAt; + + @Column(nullable = false, length = 50) + private String ipAddress; +} diff --git a/backend/src/main/java/com/threeriversbank/repository/ApplicationRepository.java b/backend/src/main/java/com/threeriversbank/repository/ApplicationRepository.java new file mode 100644 index 0000000..5c2f08a --- /dev/null +++ b/backend/src/main/java/com/threeriversbank/repository/ApplicationRepository.java @@ -0,0 +1,21 @@ +package com.threeriversbank.repository; + +import com.threeriversbank.model.entity.BusinessCreditCardApplication; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +public interface ApplicationRepository extends JpaRepository { + + Optional findByApplicationId(String applicationId); + + @Query("SELECT COUNT(a) FROM BusinessCreditCardApplication a WHERE a.ipAddress = :ipAddress AND a.submittedAt >= :since") + long countByIpAddressSince(String ipAddress, LocalDateTime since); + + List findByCreditCardId(Long creditCardId); +} diff --git a/backend/src/main/java/com/threeriversbank/service/ApplicationService.java b/backend/src/main/java/com/threeriversbank/service/ApplicationService.java new file mode 100644 index 0000000..413f154 --- /dev/null +++ b/backend/src/main/java/com/threeriversbank/service/ApplicationService.java @@ -0,0 +1,161 @@ +package com.threeriversbank.service; + +import com.threeriversbank.model.dto.ApplicationRequestDto; +import com.threeriversbank.model.dto.ApplicationResponseDto; +import com.threeriversbank.model.entity.BusinessCreditCardApplication; +import com.threeriversbank.model.entity.CreditCard; +import com.threeriversbank.repository.ApplicationRepository; +import com.threeriversbank.repository.CreditCardRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.crypto.Cipher; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.Period; +import java.time.format.DateTimeParseException; +import java.util.Base64; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ApplicationService { + + private static final int MAX_APPLICATIONS_PER_DAY = 3; + + @Value("${app.encryption.key:ThreeRiversBank!}") + private String encryptionKey; + + private final ApplicationRepository applicationRepository; + private final CreditCardRepository creditCardRepository; + + @Transactional + public ApplicationResponseDto submitApplication(ApplicationRequestDto request, String ipAddress) { + log.info("Processing application for card ID: {} from IP: {}", request.getCreditCardId(), ipAddress); + + // Rate limiting check + LocalDateTime oneDayAgo = LocalDateTime.now().minusDays(1); + long applicationsToday = applicationRepository.countByIpAddressSince(ipAddress, oneDayAgo); + if (applicationsToday >= MAX_APPLICATIONS_PER_DAY) { + log.warn("Rate limit exceeded for IP: {}", ipAddress); + throw new IllegalStateException("Maximum of " + MAX_APPLICATIONS_PER_DAY + " applications per day exceeded. Please try again tomorrow."); + } + + // Validate card exists + CreditCard card = creditCardRepository.findById(request.getCreditCardId()) + .orElseThrow(() -> new IllegalArgumentException("Credit card not found with ID: " + request.getCreditCardId())); + + // Validate age (18+) + LocalDate dob; + try { + dob = LocalDate.parse(request.getDateOfBirth()); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException("Invalid date of birth format. Use YYYY-MM-DD."); + } + if (Period.between(dob, LocalDate.now()).getYears() < 18) { + throw new IllegalArgumentException("Applicant must be at least 18 years old."); + } + + // Build entity + BusinessCreditCardApplication application = new BusinessCreditCardApplication(); + application.setApplicationId(generateApplicationId()); + application.setCreditCardId(request.getCreditCardId()); + + // Business info + application.setBusinessLegalName(request.getBusinessLegalName()); + application.setDbaName(request.getDbaName()); + application.setBusinessStructure(request.getBusinessStructure()); + application.setTaxId(encrypt(request.getTaxId())); + application.setIndustry(request.getIndustry()); + application.setYearsInBusiness(request.getYearsInBusiness()); + application.setNumberOfEmployees(request.getNumberOfEmployees()); + application.setAnnualRevenue(request.getAnnualRevenue()); + application.setBusinessStreet(request.getBusinessStreet()); + application.setBusinessCity(request.getBusinessCity()); + application.setBusinessState(request.getBusinessState()); + application.setBusinessZip(request.getBusinessZip()); + application.setBusinessPhone(request.getBusinessPhone()); + application.setBusinessWebsite(request.getBusinessWebsite()); + + // Personal info + application.setOwnerFirstName(request.getOwnerFirstName()); + application.setOwnerLastName(request.getOwnerLastName()); + application.setDateOfBirth(dob); + application.setSsn(encrypt(request.getSsn())); + application.setOwnerEmail(request.getOwnerEmail()); + application.setOwnerStreet(request.getOwnerStreet()); + application.setOwnerCity(request.getOwnerCity()); + application.setOwnerState(request.getOwnerState()); + application.setOwnerZip(request.getOwnerZip()); + application.setOwnerPhone(request.getOwnerPhone()); + application.setOwnershipPercentage(request.getOwnershipPercentage()); + application.setOwnerTitle(request.getOwnerTitle()); + application.setAnnualPersonalIncome(request.getAnnualPersonalIncome()); + + // Card preferences + application.setRequestedCreditLimit(request.getRequestedCreditLimit()); + application.setNumberOfEmployeeCards(request.getNumberOfEmployeeCards()); + + // Terms + application.setAgreedToTerms(request.getAgreedToTerms()); + application.setConsentToCreditCheck(request.getConsentToCreditCheck()); + application.setElectronicSignature(request.getElectronicSignature()); + + // Status and metadata + application.setStatus("Pending"); + application.setSubmittedAt(LocalDateTime.now()); + application.setIpAddress(ipAddress); + + BusinessCreditCardApplication saved = applicationRepository.save(application); + log.info("Application saved with ID: {}", saved.getApplicationId()); + + return ApplicationResponseDto.builder() + .applicationId(saved.getApplicationId()) + .id(saved.getId()) + .status(saved.getStatus()) + .submittedAt(saved.getSubmittedAt()) + .cardName(card.getName()) + .ownerFirstName(saved.getOwnerFirstName()) + .ownerLastName(saved.getOwnerLastName()) + .ownerEmail(saved.getOwnerEmail()) + .message("Your application has been submitted successfully.") + .expectedDecisionTimeline("Decision in 5-7 business days") + .build(); + } + + private String generateApplicationId() { + return "APP-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase(); + } + + private String encrypt(String data) { + try { + byte[] keyBytes = encryptionKey.getBytes(StandardCharsets.UTF_8); + // Ensure exactly 16 bytes for AES-128 + byte[] paddedKey = new byte[16]; + System.arraycopy(keyBytes, 0, paddedKey, 0, Math.min(keyBytes.length, 16)); + SecretKeySpec keySpec = new SecretKeySpec(paddedKey, "AES"); + Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + byte[] iv = new byte[16]; + new SecureRandom().nextBytes(iv); + IvParameterSpec ivSpec = new IvParameterSpec(iv); + cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); + byte[] encrypted = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8)); + // Prepend IV to ciphertext for storage + byte[] ivAndCiphertext = new byte[16 + encrypted.length]; + System.arraycopy(iv, 0, ivAndCiphertext, 0, 16); + System.arraycopy(encrypted, 0, ivAndCiphertext, 16, encrypted.length); + return Base64.getEncoder().encodeToString(ivAndCiphertext); + } catch (Exception e) { + log.error("Encryption error", e); + throw new RuntimeException("Failed to encrypt sensitive data", e); + } + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index d17d2c2..6dc7d71 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -74,7 +74,10 @@ logging: org.springframework.web: INFO org.hibernate: INFO -# Springdoc OpenAPI +# Application encryption configuration +app: + encryption: + key: ${APPLICATION_ENCRYPTION_KEY:ThreeRiversBank!} springdoc: api-docs: path: /api-docs diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5173d8c..6d7047a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,13 +10,16 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", + "@hookform/resolvers": "^5.2.2", "@mui/icons-material": "^7.3.5", "@mui/material": "^7.3.5", "@tanstack/react-query": "^5.90.10", "axios": "^1.13.2", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-router-dom": "^7.9.6" + "react-hook-form": "^7.73.1", + "react-router-dom": "^7.9.6", + "yup": "^1.7.1" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -1063,6 +1066,18 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1719,6 +1734,12 @@ "win32" ] }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tanstack/query-core": { "version": "5.90.10", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.10.tgz", @@ -3393,6 +3414,12 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==", + "license": "MIT" + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -3430,6 +3457,22 @@ "react": "^19.2.0" } }, + "node_modules/react-hook-form": { + "version": "7.73.1", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.73.1.tgz", + "integrity": "sha512-VAfVYOPcx3piiEVQy95vyFmBwbVUsP/AUIN+mpFG8h11yshDd444nn0VyfaGWSRnhOLVgiDu7HIuBtAIzxn9dA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz", @@ -3679,6 +3722,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -3696,6 +3745,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", + "license": "MIT" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3709,6 +3764,18 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", @@ -3886,6 +3953,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yup": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz", + "integrity": "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==", + "license": "MIT", + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + }, "node_modules/zod": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", diff --git a/frontend/package.json b/frontend/package.json index aca9b7b..9c30567 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,13 +12,16 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", + "@hookform/resolvers": "^5.2.2", "@mui/icons-material": "^7.3.5", "@mui/material": "^7.3.5", "@tanstack/react-query": "^5.90.10", "axios": "^1.13.2", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-router-dom": "^7.9.6" + "react-hook-form": "^7.73.1", + "react-router-dom": "^7.9.6", + "yup": "^1.7.1" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 9838433..3899794 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -11,6 +11,8 @@ import Footer from './components/layout/Footer'; import HomePage from './pages/HomePage'; import CardComparisonPage from './pages/CardComparisonPage'; import CardDetailsPage from './pages/CardDetailsPage'; +import ApplicationFormPage from './pages/ApplicationFormPage'; +import ApplicationConfirmationPage from './pages/ApplicationConfirmationPage'; const queryClient = new QueryClient({ defaultOptions: { @@ -40,6 +42,8 @@ function App() { } /> } /> } /> + } /> + } />