merge main into branch

This commit is contained in:
2026-04-16 08:12:46 -06:00
49 changed files with 2187 additions and 400 deletions

View File

@@ -9,21 +9,53 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs: jobs:
build-and-deploy: build-backend:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
packages: write packages: write
id-token: write
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Set image names (lowercase) - name: Set image name (lowercase)
run: | run: |
OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
echo "BACKEND_IMAGE=ghcr.io/${OWNER}/petshop-backend" >> $GITHUB_ENV echo "BACKEND_IMAGE=ghcr.io/${OWNER}/petshop-backend" >> $GITHUB_ENV
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3.3.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push backend image
uses: docker/build-push-action@v6
with:
context: ./backend
push: true
tags: ${{ env.BACKEND_IMAGE }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
build-frontend:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4.2.2
- name: Set image name (lowercase)
run: |
OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
echo "FRONTEND_IMAGE=ghcr.io/${OWNER}/petshop-web" >> $GITHUB_ENV echo "FRONTEND_IMAGE=ghcr.io/${OWNER}/petshop-web" >> $GITHUB_ENV
- name: Log in to GitHub Container Registry - name: Log in to GitHub Container Registry
@@ -33,12 +65,8 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push backend image - name: Set up Docker Buildx
uses: docker/build-push-action@v6 uses: docker/setup-buildx-action@v3
with:
context: ./backend
push: true
tags: ${{ env.BACKEND_IMAGE }}:latest
- name: Build and push frontend image - name: Build and push frontend image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
@@ -46,8 +74,21 @@ jobs:
context: ./web context: ./web
push: true push: true
tags: ${{ env.FRONTEND_IMAGE }}:latest tags: ${{ env.FRONTEND_IMAGE }}:latest
build-args: | no-cache: true
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }}
deploy:
runs-on: ubuntu-latest
needs: [build-backend, build-frontend]
permissions:
contents: read
id-token: write
steps:
- name: Set image names (lowercase)
run: |
OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
echo "BACKEND_IMAGE=ghcr.io/${OWNER}/petshop-backend" >> $GITHUB_ENV
echo "FRONTEND_IMAGE=ghcr.io/${OWNER}/petshop-web" >> $GITHUB_ENV
- name: Log in to Azure - name: Log in to Azure
uses: azure/login@v2 uses: azure/login@v2
@@ -61,11 +102,13 @@ jobs:
az containerapp update \ az containerapp update \
--name ${{ secrets.AZURE_BACKEND_APP_NAME }} \ --name ${{ secrets.AZURE_BACKEND_APP_NAME }} \
--resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \ --resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \
--image ${{ env.BACKEND_IMAGE }}:latest --image ${{ env.BACKEND_IMAGE }}:latest \
--revision-suffix r${{ github.run_number }}
- name: Deploy frontend - name: Deploy frontend
run: | run: |
az containerapp update \ az containerapp update \
--name ${{ secrets.AZURE_FRONTEND_APP_NAME }} \ --name ${{ secrets.AZURE_FRONTEND_APP_NAME }} \
--resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \ --resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \
--image ${{ env.FRONTEND_IMAGE }}:latest --image ${{ env.FRONTEND_IMAGE }}:latest \
--revision-suffix r${{ github.run_number }}

View File

@@ -8,6 +8,8 @@ import androidx.lifecycle.ViewModel;
import com.example.petstoremobile.dtos.ActivityLogDTO; import com.example.petstoremobile.dtos.ActivityLogDTO;
import com.example.petstoremobile.dtos.DropdownDTO; import com.example.petstoremobile.dtos.DropdownDTO;
import com.example.petstoremobile.repositories.ActivityLogRepository; import com.example.petstoremobile.repositories.ActivityLogRepository;
import java.util.Locale;
import com.example.petstoremobile.repositories.StoreRepository; import com.example.petstoremobile.repositories.StoreRepository;
import com.example.petstoremobile.utils.Resource; import com.example.petstoremobile.utils.Resource;
@@ -89,7 +91,7 @@ public class ActivityLogListViewModel extends ViewModel {
} }
public void setRoleFilter(String role) { public void setRoleFilter(String role) {
currentRole = "All Roles".equals(role) ? null : role; currentRole = (role == null || "All Roles".equals(role)) ? null : role.toUpperCase(Locale.ROOT);
loadLogs(true); loadLogs(true);
} }

View File

@@ -1,4 +1,5 @@
JWT_SECRET=<run: openssl rand -base64 32>
STRIPE_SECRET_KEY=sk_test_... STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
OPENROUTER_API_KEY=sk-or-v1-... OPENROUTER_API_KEY=sk-or-v1-...
RESEND_API_KEY=re_... RESEND_API_KEY=re_...
RESEND_FROM=PetShop <no-reply@yourdomain.com>

View File

@@ -10,4 +10,4 @@ FROM eclipse-temurin:25-jre
WORKDIR /app WORKDIR /app
COPY --from=build /app/target/*.jar app.jar COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["java","-jar","app.jar"] ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-XX:+UseG1GC", "-jar", "app.jar"]

View File

@@ -0,0 +1,145 @@
WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
WARNING: sun.misc.Unsafe::staticFieldBase has been called by com.google.inject.internal.aop.HiddenClassDefiner (file:/nix/store/snv87hz5j78nqiqqamlf1mimbkmcrl6l-maven-3.9.11/maven/lib/guice-5.1.0-classes.jar)
WARNING: Please consider reporting this to the maintainers of class com.google.inject.internal.aop.HiddenClassDefiner
WARNING: sun.misc.Unsafe::staticFieldBase will be removed in a future release
[INFO] Scanning for projects...
[INFO]
[INFO] ------------------------< com.petshop:backend >-------------------------
[INFO] Building PetShop Backend 1.0.0
[INFO] from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- enforcer:3.5.0:enforce (require-java-25) @ backend ---
[INFO] Rule 0: org.apache.maven.enforcer.rules.version.RequireJavaVersion passed
[INFO]
[INFO] --- resources:3.3.1:resources (default-resources) @ backend ---
[INFO] Copying 2 resources from src/main/resources to target/classes
[INFO] Copying 14 resources from src/main/resources to target/classes
[INFO]
[INFO] --- compiler:3.14.1:compile (default-compile) @ backend ---
[INFO] Nothing to compile - all classes are up to date.
[INFO]
[INFO] --- resources:3.3.1:testResources (default-testResources) @ backend ---
[INFO] skip non existing resourceDirectory /home/user/threaded-parity/backend/src/test/resources
[INFO]
[INFO] --- compiler:3.14.1:testCompile (default-testCompile) @ backend ---
[INFO] Nothing to compile - all classes are up to date.
[INFO]
[INFO] --- surefire:3.5.4:test (default-test) @ backend ---
[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider
[INFO]
[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------
[INFO] Running com.petshop.backend.service.UserServiceTest
Mockito is currently self-attaching to enable the inline-mock-maker. This will no longer work in future releases of the JDK. Please add Mockito as an agent to your build as described in Mockito's documentation: https://javadoc.io/doc/org.mockito/mockito-core/latest/org.mockito/org/mockito/Mockito.html#0.3
OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
WARNING: A Java agent has been loaded dynamically (/home/user/.m2/repository/net/bytebuddy/byte-buddy-agent/1.17.8/byte-buddy-agent-1.17.8.jar)
WARNING: If a serviceability tool is in use, please run with -XX:+EnableDynamicAgentLoading to hide this warning
WARNING: If a serviceability tool is not in use, please run with -Djdk.instrument.traceUsage for more information
WARNING: Dynamic loading of agents will be disallowed by default in a future release
[ERROR] Tests run: 10, Failures: 3, Errors: 2, Skipped: 0, Time elapsed: 0.624 s <<< FAILURE! -- in com.petshop.backend.service.UserServiceTest
[ERROR] com.petshop.backend.service.UserServiceTest.scopedUpdateDeniesRoleEscalation -- Time elapsed: 0.007 s <<< FAILURE!
org.opentest4j.AssertionFailedError: Unexpected exception type thrown, expected: <org.springframework.security.access.AccessDeniedException> but was: <com.petshop.backend.exception.ResourceNotFoundException>
at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:158)
at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:68)
at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:35)
at org.junit.jupiter.api.Assertions.assertThrows(Assertions.java:3223)
at com.petshop.backend.service.UserServiceTest.scopedUpdateDeniesRoleEscalation(UserServiceTest.java:181)
Caused by: com.petshop.backend.exception.ResourceNotFoundException: User not found with id: 2
at com.petshop.backend.service.UserService.lambda$updateUser$0(UserService.java:113)
at java.base/java.util.Optional.orElseThrow(Optional.java:403)
at com.petshop.backend.service.UserService.updateUser(UserService.java:113)
at com.petshop.backend.service.UserServiceTest.lambda$scopedUpdateDeniesRoleEscalation$0(UserServiceTest.java:181)
at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:54)
... 3 more
[ERROR] com.petshop.backend.service.UserServiceTest.updateUserTreatsWrongScopedRoleAsNotFound -- Time elapsed: 0.005 s <<< ERROR!
org.mockito.exceptions.misusing.UnnecessaryStubbingException:
Unnecessary stubbings detected.
Clean & maintainable test code requires zero unnecessary code.
Following stubbings are unnecessary (click to navigate to relevant line of code):
1. -> at com.petshop.backend.service.UserServiceTest.updateUserTreatsWrongScopedRoleAsNotFound(UserServiceTest.java:75)
Please remove unnecessary stubbings or use 'lenient' strictness. More info: javadoc for UnnecessaryStubbingException class.
at org.mockito.junit.jupiter.MockitoExtension.lambda$afterEach$2(MockitoExtension.java:200)
at java.base/java.util.Optional.ifPresent(Optional.java:178)
at org.mockito.junit.jupiter.MockitoExtension.afterEach(MockitoExtension.java:198)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1604)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1604)
[ERROR] com.petshop.backend.service.UserServiceTest.updateUserDeniesPromotingAnotherUserToAdmin -- Time elapsed: 0.004 s <<< FAILURE!
org.opentest4j.AssertionFailedError: Unexpected exception type thrown, expected: <org.springframework.security.access.AccessDeniedException> but was: <com.petshop.backend.exception.ResourceNotFoundException>
at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:158)
at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:68)
at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:35)
at org.junit.jupiter.api.Assertions.assertThrows(Assertions.java:3223)
at com.petshop.backend.service.UserServiceTest.updateUserDeniesPromotingAnotherUserToAdmin(UserServiceTest.java:167)
Caused by: com.petshop.backend.exception.ResourceNotFoundException: User not found with id: 2
at com.petshop.backend.service.UserService.lambda$updateUser$0(UserService.java:113)
at java.base/java.util.Optional.orElseThrow(Optional.java:403)
at com.petshop.backend.service.UserService.updateUser(UserService.java:113)
at com.petshop.backend.service.UserService.updateUser(UserService.java:107)
at com.petshop.backend.service.UserServiceTest.lambda$updateUserDeniesPromotingAnotherUserToAdmin$0(UserServiceTest.java:167)
at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:54)
... 3 more
[ERROR] com.petshop.backend.service.UserServiceTest.updateUserAllowsEditingOwnAdminAccount -- Time elapsed: 0.003 s <<< ERROR!
com.petshop.backend.exception.ResourceNotFoundException: User not found with id: 1
at com.petshop.backend.service.UserService.lambda$updateUser$0(UserService.java:113)
at java.base/java.util.Optional.orElseThrow(Optional.java:403)
at com.petshop.backend.service.UserService.updateUser(UserService.java:113)
at com.petshop.backend.service.UserService.updateUser(UserService.java:107)
at com.petshop.backend.service.UserServiceTest.updateUserAllowsEditingOwnAdminAccount(UserServiceTest.java:152)
[ERROR] com.petshop.backend.service.UserServiceTest.updateUserDeniesEditingAnotherAdmin -- Time elapsed: 0.005 s <<< FAILURE!
org.opentest4j.AssertionFailedError: Unexpected exception type thrown, expected: <org.springframework.security.access.AccessDeniedException> but was: <com.petshop.backend.exception.ResourceNotFoundException>
at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:158)
at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:68)
at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:35)
at org.junit.jupiter.api.Assertions.assertThrows(Assertions.java:3223)
at com.petshop.backend.service.UserServiceTest.updateUserDeniesEditingAnotherAdmin(UserServiceTest.java:65)
Caused by: com.petshop.backend.exception.ResourceNotFoundException: User not found with id: 2
at com.petshop.backend.service.UserService.lambda$updateUser$0(UserService.java:113)
at java.base/java.util.Optional.orElseThrow(Optional.java:403)
at com.petshop.backend.service.UserService.updateUser(UserService.java:113)
at com.petshop.backend.service.UserService.updateUser(UserService.java:107)
at com.petshop.backend.service.UserServiceTest.lambda$updateUserDeniesEditingAnotherAdmin$0(UserServiceTest.java:65)
at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:54)
... 3 more
[INFO]
[INFO] Results:
[INFO]
[ERROR] Failures:
[ERROR] UserServiceTest.scopedUpdateDeniesRoleEscalation:181 Unexpected exception type thrown, expected: <org.springframework.security.access.AccessDeniedException> but was: <com.petshop.backend.exception.ResourceNotFoundException>
[ERROR] UserServiceTest.updateUserDeniesEditingAnotherAdmin:65 Unexpected exception type thrown, expected: <org.springframework.security.access.AccessDeniedException> but was: <com.petshop.backend.exception.ResourceNotFoundException>
[ERROR] UserServiceTest.updateUserDeniesPromotingAnotherUserToAdmin:167 Unexpected exception type thrown, expected: <org.springframework.security.access.AccessDeniedException> but was: <com.petshop.backend.exception.ResourceNotFoundException>
[ERROR] Errors:
[ERROR] UserServiceTest.updateUserAllowsEditingOwnAdminAccount:152 » ResourceNotFound User not found with id: 1
[ERROR] UserServiceTest.updateUserTreatsWrongScopedRoleAsNotFound » UnnecessaryStubbing
Unnecessary stubbings detected.
Clean & maintainable test code requires zero unnecessary code.
Following stubbings are unnecessary (click to navigate to relevant line of code):
1. -> at com.petshop.backend.service.UserServiceTest.updateUserTreatsWrongScopedRoleAsNotFound(UserServiceTest.java:75)
Please remove unnecessary stubbings or use 'lenient' strictness. More info: javadoc for UnnecessaryStubbingException class.
[INFO]
[ERROR] Tests run: 10, Failures: 3, Errors: 2, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD FAILURE
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 2.040 s
[INFO] Finished at: 2026-04-16T08:04:25-06:00
[INFO] ------------------------------------------------------------------------
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.5.4:test (default-test) on project backend: There are test failures.
[ERROR]
[ERROR] See /home/user/threaded-parity/backend/target/surefire-reports for the individual test results.
[ERROR] See dump files (if any exist) [date].dump, [date]-jvmRun[N].dump and [date].dumpstream.
[ERROR] -> [Help 1]
[ERROR]
[ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch.
[ERROR] Re-run Maven using the -X switch to enable full debug logging.
[ERROR]
[ERROR] For more information about the errors and possible solutions, please read the following articles:
[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoFailureException

View File

@@ -38,6 +38,16 @@
<artifactId>spring-boot-starter-security</artifactId> <artifactId>spring-boot-starter-security</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId> <artifactId>spring-boot-starter-validation</artifactId>

View File

@@ -4,10 +4,12 @@ import com.petshop.backend.config.FlywayContextInitializer;
import org.springframework.boot.builder.SpringApplicationBuilder; import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.web.config.EnableSpringDataWebSupport; import org.springframework.data.web.config.EnableSpringDataWebSupport;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication @SpringBootApplication
@EnableScheduling @EnableScheduling
@EnableAsync
@EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO) @EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO)
public class BackendApplication { public class BackendApplication {
public static void main(String[] args) { public static void main(String[] args) {

View File

@@ -10,7 +10,7 @@ public class ActivityLoggingFilterRegistrationConfig {
@Bean @Bean
public FilterRegistrationBean<ActivityLoggingFilter> activityLoggingFilterRegistration(ActivityLoggingFilter activityLoggingFilter) { public FilterRegistrationBean<ActivityLoggingFilter> activityLoggingFilterRegistration(ActivityLoggingFilter activityLoggingFilter) {
FilterRegistrationBean<ActivityLoggingFilter> registrationBean = new FilterRegistrationBean<>(activityLoggingFilter); FilterRegistrationBean<ActivityLoggingFilter> registrationBean = new FilterRegistrationBean<>(activityLoggingFilter);
registrationBean.setEnabled(false); registrationBean.setEnabled(true);
return registrationBean; return registrationBean;
} }
} }

View File

@@ -0,0 +1,24 @@
package com.petshop.backend.config;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.TimeUnit;
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager("userAuthCache");
manager.setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(60, TimeUnit.SECONDS)
.maximumSize(1000));
return manager;
}
}

View File

@@ -0,0 +1,37 @@
package com.petshop.backend.controller;
import com.petshop.backend.dto.activity.ActivityLogResponse;
import com.petshop.backend.service.ActivityLogService;
import java.time.LocalDate;
import java.util.List;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/activity-logs")
@PreAuthorize("hasRole('ADMIN')")
public class ActivityLogController {
private final ActivityLogService activityLogService;
public ActivityLogController(ActivityLogService activityLogService) {
this.activityLogService = activityLogService;
}
@GetMapping
public ResponseEntity<List<ActivityLogResponse>> getActivityLogs(
@RequestParam(defaultValue = "2000") int limit,
@RequestParam(required = false) Long storeId,
@RequestParam(required = false) String role,
@RequestParam(required = false) String search,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
int safeLimit = Math.min(Math.max(1, limit), 10000);
return ResponseEntity.ok(activityLogService.getLogs(safeLimit, storeId, role, search, startDate, endDate));
}
}

View File

@@ -15,6 +15,7 @@ import com.petshop.backend.entity.StoreLocation;
import com.petshop.backend.entity.User; import com.petshop.backend.entity.User;
import com.petshop.backend.repository.UserRepository; import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.security.JwtUtil; import com.petshop.backend.security.JwtUtil;
import com.petshop.backend.security.UserAuthCacheService;
import com.petshop.backend.service.ActivityLogService; import com.petshop.backend.service.ActivityLogService;
import com.petshop.backend.service.AvatarStorageService; import com.petshop.backend.service.AvatarStorageService;
import com.petshop.backend.service.EmailService; import com.petshop.backend.service.EmailService;
@@ -58,8 +59,9 @@ public class AuthController {
private final ActivityLogService activityLogService; private final ActivityLogService activityLogService;
private final PasswordResetService passwordResetService; private final PasswordResetService passwordResetService;
private final EmailService emailService; private final EmailService emailService;
private final UserAuthCacheService userAuthCacheService;
public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, AvatarStorageService avatarStorageService, ActivityLogService activityLogService, PasswordResetService passwordResetService, EmailService emailService) { public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, AvatarStorageService avatarStorageService, ActivityLogService activityLogService, PasswordResetService passwordResetService, EmailService emailService, UserAuthCacheService userAuthCacheService) {
this.authenticationManager = authenticationManager; this.authenticationManager = authenticationManager;
this.userRepository = userRepository; this.userRepository = userRepository;
this.jwtUtil = jwtUtil; this.jwtUtil = jwtUtil;
@@ -68,6 +70,7 @@ public class AuthController {
this.activityLogService = activityLogService; this.activityLogService = activityLogService;
this.passwordResetService = passwordResetService; this.passwordResetService = passwordResetService;
this.emailService = emailService; this.emailService = emailService;
this.userAuthCacheService = userAuthCacheService;
} }
@PostMapping("/register") @PostMapping("/register")
@@ -263,6 +266,7 @@ public class AuthController {
error.put("message", "Username, email, or phone already exists"); error.put("message", "Username, email, or phone already exists");
return ResponseEntity.status(HttpStatus.CONFLICT).body(error); return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
} }
userAuthCacheService.evict(updatedUser.getId());
return ResponseEntity.ok(toUserInfoResponse(updatedUser)); return ResponseEntity.ok(toUserInfoResponse(updatedUser));
} }

View File

@@ -3,6 +3,7 @@ package com.petshop.backend.controller;
import com.petshop.backend.dto.sale.SaleRequest; import com.petshop.backend.dto.sale.SaleRequest;
import com.petshop.backend.dto.sale.SaleResponse; import com.petshop.backend.dto.sale.SaleResponse;
import com.petshop.backend.service.SaleService; import com.petshop.backend.service.SaleService;
import com.petshop.backend.util.AuthenticationHelper;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
@@ -21,6 +22,13 @@ public class SaleController {
this.saleService = saleService; this.saleService = saleService;
} }
@GetMapping("/my")
@PreAuthorize("hasAnyRole('CUSTOMER', 'ADMIN')")
public ResponseEntity<Page<SaleResponse>> getMyOrders(Pageable pageable) {
Long userId = AuthenticationHelper.getAuthenticatedUserId();
return ResponseEntity.ok(saleService.getAllSales(null, null, null, false, userId, pageable));
}
@GetMapping @GetMapping
@PreAuthorize("hasAnyRole('STAFF', 'ADMIN')") @PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
public ResponseEntity<Page<SaleResponse>> getAllSales( public ResponseEntity<Page<SaleResponse>> getAllSales(

View File

@@ -0,0 +1,126 @@
package com.petshop.backend.dto.activity;
import java.time.LocalDateTime;
public class ActivityLogResponse {
private Long logId;
private Long userId;
private String username;
private String fullName;
private String role;
private Long storeId;
private String storeName;
private String usernameSnapshot;
private String fullNameSnapshot;
private String roleSnapshot;
private String storeNameSnapshot;
private String activity;
private LocalDateTime logTimestamp;
public ActivityLogResponse() {
}
public Long getLogId() {
return logId;
}
public void setLogId(Long logId) {
this.logId = logId;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getFullName() {
return fullName;
}
public void setFullName(String fullName) {
this.fullName = fullName;
}
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
}
public Long getStoreId() {
return storeId;
}
public void setStoreId(Long storeId) {
this.storeId = storeId;
}
public String getStoreName() {
return storeName;
}
public void setStoreName(String storeName) {
this.storeName = storeName;
}
public String getUsernameSnapshot() {
return usernameSnapshot;
}
public void setUsernameSnapshot(String usernameSnapshot) {
this.usernameSnapshot = usernameSnapshot;
}
public String getFullNameSnapshot() {
return fullNameSnapshot;
}
public void setFullNameSnapshot(String fullNameSnapshot) {
this.fullNameSnapshot = fullNameSnapshot;
}
public String getRoleSnapshot() {
return roleSnapshot;
}
public void setRoleSnapshot(String roleSnapshot) {
this.roleSnapshot = roleSnapshot;
}
public String getStoreNameSnapshot() {
return storeNameSnapshot;
}
public void setStoreNameSnapshot(String storeNameSnapshot) {
this.storeNameSnapshot = storeNameSnapshot;
}
public String getActivity() {
return activity;
}
public void setActivity(String activity) {
this.activity = activity;
}
public LocalDateTime getLogTimestamp() {
return logTimestamp;
}
public void setLogTimestamp(LocalDateTime logTimestamp) {
this.logTimestamp = logTimestamp;
}
}

View File

@@ -1,9 +1,6 @@
package com.petshop.backend.dto.chat; package com.petshop.backend.dto.chat;
import jakarta.validation.constraints.NotBlank;
public class ConversationRequest { public class ConversationRequest {
@NotBlank(message = "Initial message is required")
private String message; private String message;
public ConversationRequest() { public ConversationRequest() {

View File

@@ -0,0 +1,148 @@
package com.petshop.backend.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.Immutable;
import java.time.LocalDateTime;
import java.util.Objects;
@Entity
@Immutable
@Table(name = "activityLog")
public class ActivityLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long logId;
@ManyToOne
@JoinColumn(name = "userId", nullable = false)
private User user;
@ManyToOne
@JoinColumn(name = "storeId")
private StoreLocation store;
@Column(length = 50)
private String usernameSnapshot;
@Column(length = 100)
private String fullNameSnapshot;
@Column(length = 20)
private String roleSnapshot;
@Column(length = 100)
private String storeNameSnapshot;
@Column(nullable = false, columnDefinition = "TEXT")
private String activity;
@Column(nullable = false)
private LocalDateTime logTimestamp = LocalDateTime.now();
public ActivityLog() {
}
public ActivityLog(Long logId, User user, String activity, LocalDateTime logTimestamp) {
this.logId = logId;
this.user = user;
this.activity = activity;
this.logTimestamp = logTimestamp;
}
public Long getLogId() {
return logId;
}
public void setLogId(Long logId) {
this.logId = logId;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
public StoreLocation getStore() {
return store;
}
public void setStore(StoreLocation store) {
this.store = store;
}
public String getUsernameSnapshot() {
return usernameSnapshot;
}
public void setUsernameSnapshot(String usernameSnapshot) {
this.usernameSnapshot = usernameSnapshot;
}
public String getFullNameSnapshot() {
return fullNameSnapshot;
}
public void setFullNameSnapshot(String fullNameSnapshot) {
this.fullNameSnapshot = fullNameSnapshot;
}
public String getRoleSnapshot() {
return roleSnapshot;
}
public void setRoleSnapshot(String roleSnapshot) {
this.roleSnapshot = roleSnapshot;
}
public String getStoreNameSnapshot() {
return storeNameSnapshot;
}
public void setStoreNameSnapshot(String storeNameSnapshot) {
this.storeNameSnapshot = storeNameSnapshot;
}
public String getActivity() {
return activity;
}
public void setActivity(String activity) {
this.activity = activity;
}
public LocalDateTime getLogTimestamp() {
return logTimestamp;
}
public void setLogTimestamp(LocalDateTime logTimestamp) {
this.logTimestamp = logTimestamp;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ActivityLog that = (ActivityLog) o;
return Objects.equals(logId, that.logId);
}
@Override
public int hashCode() {
return Objects.hash(logId);
}
@Override
public String toString() {
return "ActivityLog{" +
"logId=" + logId +
", user=" + user +
", activity='" + activity + '\'' +
", logTimestamp=" + logTimestamp +
'}';
}
}

View File

@@ -0,0 +1,18 @@
package com.petshop.backend.repository;
import com.petshop.backend.entity.ActivityLog;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface ActivityLogRepository extends JpaRepository<ActivityLog, Long>, JpaSpecificationExecutor<ActivityLog> {
boolean existsByUser_Id(Long userId);
@Query("select a from ActivityLog a order by a.logTimestamp desc, a.logId desc")
List<ActivityLog> findRecent(Pageable pageable);
}

View File

@@ -41,11 +41,11 @@ public interface PetRepository extends JpaRepository<Pet, Long> {
Optional<Pet> findByIdAndOwner_Id(Long id, Long ownerId); Optional<Pet> findByIdAndOwner_Id(Long id, Long ownerId);
@Lock(LockModeType.PESSIMISTIC_WRITE) @Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Pet p WHERE p.petId = :id") @Query("SELECT p FROM Pet p WHERE p.id = :id")
Optional<Pet> findByIdForUpdate(@Param("id") Long id); Optional<Pet> findByIdForUpdate(@Param("id") Long id);
@Query("SELECT p FROM Pet p WHERE " + @Query("SELECT p FROM Pet p LEFT JOIN p.owner o WHERE " +
"(:q IS NULL OR LOWER(p.petName) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(p.petSpecies) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(COALESCE(p.petBreed, '')) LIKE LOWER(CONCAT('%', :q, '%'))) AND " + "(:q IS NULL OR LOWER(p.petName) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(p.petSpecies) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(COALESCE(p.petBreed, '')) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(COALESCE(o.firstName, '')) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(COALESCE(o.lastName, '')) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(CONCAT(COALESCE(o.firstName, ''), ' ', COALESCE(o.lastName, ''))) LIKE LOWER(CONCAT('%', :q, '%'))) AND " +
"(:species IS NULL OR LOWER(p.petSpecies) = LOWER(:species)) AND " + "(:species IS NULL OR LOWER(p.petSpecies) = LOWER(:species)) AND " +
"(:breed IS NULL OR LOWER(COALESCE(p.petBreed, '')) = LOWER(:breed)) AND " + "(:breed IS NULL OR LOWER(COALESCE(p.petBreed, '')) = LOWER(:breed)) AND " +
"(:status IS NULL OR LOWER(p.petStatus) = LOWER(:status)) AND " + "(:status IS NULL OR LOWER(p.petStatus) = LOWER(:status)) AND " +

View File

@@ -2,7 +2,6 @@ package com.petshop.backend.security;
import com.petshop.backend.entity.User; import com.petshop.backend.entity.User;
import com.petshop.backend.exception.ApiErrorResponder; import com.petshop.backend.exception.ApiErrorResponder;
import com.petshop.backend.repository.UserRepository;
import io.jsonwebtoken.JwtException; import io.jsonwebtoken.JwtException;
import jakarta.servlet.FilterChain; import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
@@ -16,16 +15,18 @@ import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException; import java.io.IOException;
import java.util.Date;
@Component @Component
public class JwtAuthenticationFilter extends OncePerRequestFilter { public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil; private final JwtUtil jwtUtil;
private final UserRepository userRepository; private final UserAuthCacheService userAuthCacheService;
private final ApiErrorResponder apiErrorResponder; private final ApiErrorResponder apiErrorResponder;
public JwtAuthenticationFilter(JwtUtil jwtUtil, UserRepository userRepository, ApiErrorResponder apiErrorResponder) { public JwtAuthenticationFilter(JwtUtil jwtUtil, UserAuthCacheService userAuthCacheService, ApiErrorResponder apiErrorResponder) {
this.jwtUtil = jwtUtil; this.jwtUtil = jwtUtil;
this.userRepository = userRepository; this.userAuthCacheService = userAuthCacheService;
this.apiErrorResponder = apiErrorResponder; this.apiErrorResponder = apiErrorResponder;
} }
@@ -44,30 +45,44 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
jwt = authHeader.substring(7); jwt = authHeader.substring(7);
Long userId; Long userId;
String username;
String roleStr;
Integer jwtTokenVersion;
try { try {
userId = jwtUtil.extractUserId(jwt); userId = jwtUtil.extractUserId(jwt);
username = jwtUtil.extractUsername(jwt);
roleStr = jwtUtil.extractRole(jwt);
jwtTokenVersion = jwtUtil.extractTokenVersion(jwt);
} catch (JwtException | IllegalArgumentException ex) { } catch (JwtException | IllegalArgumentException ex) {
writeUnauthorized(request, response, "Invalid or expired token", ex); writeUnauthorized(request, response, "Invalid or expired token", ex);
return; return;
} }
if (userId != null && SecurityContextHolder.getContext().getAuthentication() == null) { if (userId != null && SecurityContextHolder.getContext().getAuthentication() == null) {
User user = userRepository.findById(userId).orElse(null); if (jwtUtil.extractExpiration(jwt).before(new Date())) {
if (user == null || user.getActive() == null || !user.getActive()) {
writeUnauthorized(request, response, "User account is inactive", null);
return;
}
if (!jwtUtil.validateToken(jwt, user)) {
writeUnauthorized(request, response, "Invalid or expired token", null); writeUnauthorized(request, response, "Invalid or expired token", null);
return; return;
} }
AppPrincipal principal = new AppPrincipal( UserAuthCacheService.UserAuthData authData = userAuthCacheService.loadAuthData(userId);
user.getId(), if (authData == null || !Boolean.TRUE.equals(authData.active())) {
user.getUsername(), writeUnauthorized(request, response, "User account is inactive", null);
user.getRole(), return;
user.getTokenVersion() }
); if (!authData.tokenVersion().equals(jwtTokenVersion)) {
writeUnauthorized(request, response, "Invalid or expired token", null);
return;
}
User.Role role;
try {
role = User.Role.valueOf(roleStr);
} catch (IllegalArgumentException ex) {
writeUnauthorized(request, response, "Invalid or expired token", ex);
return;
}
AppPrincipal principal = new AppPrincipal(userId, username, role, jwtTokenVersion);
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
principal, principal,
null, null,

View File

@@ -1,5 +1,6 @@
package com.petshop.backend.security; package com.petshop.backend.security;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
@@ -21,6 +22,8 @@ import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import com.petshop.backend.config.ActivityLoggingFilter; import com.petshop.backend.config.ActivityLoggingFilter;
import java.util.List; import java.util.List;
@@ -30,6 +33,9 @@ import java.util.List;
@EnableMethodSecurity @EnableMethodSecurity
public class SecurityConfig { public class SecurityConfig {
@Value("${app.allowed-origins}")
private String allowedOriginsRaw;
private final JwtAuthenticationFilter jwtAuthFilter; private final JwtAuthenticationFilter jwtAuthFilter;
private final RateLimitFilter rateLimitFilter; private final RateLimitFilter rateLimitFilter;
private final UserDetailsService userDetailsService; private final UserDetailsService userDetailsService;
@@ -101,7 +107,7 @@ public class SecurityConfig {
@Bean @Bean
public CorsConfigurationSource corsConfigurationSource() { public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration(); CorsConfiguration config = new CorsConfiguration();
config.setAllowedOriginPatterns(List.of("http://localhost:*", "http://127.0.0.1:*")); config.setAllowedOriginPatterns(Arrays.asList(allowedOriginsRaw.split(",")));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*")); config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true); config.setAllowCredentials(true);

View File

@@ -0,0 +1,29 @@
package com.petshop.backend.security;
import com.petshop.backend.repository.UserRepository;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
@Service
public class UserAuthCacheService {
private final UserRepository userRepository;
public UserAuthCacheService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public record UserAuthData(Boolean active, Integer tokenVersion) {}
@Cacheable(value = "userAuthCache", key = "#userId")
public UserAuthData loadAuthData(Long userId) {
return userRepository.findById(userId)
.map(u -> new UserAuthData(u.getActive(), u.getTokenVersion()))
.orElse(null);
}
@CacheEvict(value = "userAuthCache", key = "#userId")
public void evict(Long userId) {
}
}

View File

@@ -1,38 +1,65 @@
package com.petshop.backend.service; package com.petshop.backend.service;
import com.petshop.backend.dto.activity.ActivityLogResponse;
import com.petshop.backend.entity.ActivityLog;
import com.petshop.backend.entity.StoreLocation; import com.petshop.backend.entity.StoreLocation;
import com.petshop.backend.entity.User; import com.petshop.backend.entity.User;
import com.petshop.backend.repository.ActivityLogRepository;
import com.petshop.backend.repository.UserRepository; import com.petshop.backend.repository.UserRepository;
import jakarta.persistence.criteria.Predicate;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
@Service @Service
public class ActivityLogService { public class ActivityLogService {
private static final Logger log = LoggerFactory.getLogger("activity"); private static final Logger log = LoggerFactory.getLogger(ActivityLogService.class);
private final ActivityLogRepository activityLogRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
public ActivityLogService(UserRepository userRepository) { public ActivityLogService(ActivityLogRepository activityLogRepository, UserRepository userRepository) {
this.activityLogRepository = activityLogRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
} }
@Transactional
public void record(Long userId, String activity) { public void record(Long userId, String activity) {
if (userId == null || activity == null || activity.isBlank()) { if (userId == null || activity == null || activity.isBlank()) {
return; return;
} }
try { try {
User user = userRepository.findById(userId).orElse(null); User managedUser = userRepository.findById(userId).orElse(null);
if (user == null) { if (managedUser == null) {
return; return;
} }
StoreLocation store = user.getPrimaryStore(); StoreLocation store = managedUser.getPrimaryStore();
String role = user.getRole() != null ? user.getRole().name() : "UNKNOWN"; ActivityLog entry = new ActivityLog();
String storeName = store != null ? store.getStoreName() : "no store"; entry.setUser(managedUser);
log.info("{} | {} | {} | {}", role, user.getUsername(), storeName, activity.trim()); entry.setStore(store);
entry.setUsernameSnapshot(managedUser.getUsername());
entry.setFullNameSnapshot(resolveFullName(managedUser));
entry.setRoleSnapshot(managedUser.getRole() != null ? managedUser.getRole().name() : null);
entry.setStoreNameSnapshot(store != null ? store.getStoreName() : null);
entry.setActivity(activity.trim());
activityLogRepository.save(entry);
log.info("[ACTIVITY] {} | {} | {} | {}",
entry.getRoleSnapshot(),
entry.getUsernameSnapshot(),
entry.getStoreNameSnapshot() != null ? entry.getStoreNameSnapshot() : "no store",
entry.getActivity());
} catch (Exception ex) { } catch (Exception ex) {
log.warn("Failed to record activity", ex); log.warn("Failed to persist activity log", ex);
} }
} }
@@ -42,4 +69,106 @@ public class ActivityLogService {
} }
record(user.getId(), activity); record(user.getId(), activity);
} }
@Transactional(readOnly = true)
public List<ActivityLogResponse> getLogs(int limit, Long storeId, String role, String search, LocalDate startDate, LocalDate endDate) {
Specification<ActivityLog> spec = (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
if (storeId != null) {
predicates.add(cb.equal(root.get("store").get("storeId"), storeId));
}
if (role != null && !role.isBlank()) {
predicates.add(cb.equal(root.get("roleSnapshot"), role));
}
if (search != null && !search.isBlank()) {
String pattern = "%" + search.toLowerCase() + "%";
Predicate searchPredicate = cb.or(
cb.like(cb.lower(root.get("activity")), pattern),
cb.like(cb.lower(root.get("fullNameSnapshot")), pattern),
cb.like(cb.lower(root.get("usernameSnapshot")), pattern)
);
predicates.add(searchPredicate);
}
if (startDate != null) {
predicates.add(cb.greaterThanOrEqualTo(root.get("logTimestamp"), startDate.atStartOfDay()));
}
if (endDate != null) {
predicates.add(cb.lessThan(root.get("logTimestamp"), endDate.plusDays(1).atStartOfDay()));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
PageRequest pageRequest = PageRequest.of(0, limit, Sort.by(Sort.Direction.DESC, "logTimestamp", "logId"));
return activityLogRepository.findAll(spec, pageRequest).stream()
.map(this::toResponse)
.toList();
}
@Transactional(readOnly = true)
public List<ActivityLogResponse> getLogs(int limit, Long storeId, String role, String search) {
return getLogs(limit, storeId, role, search, null, null);
}
@Transactional(readOnly = true)
public List<ActivityLogResponse> getLogs(int limit) {
return getLogs(limit, null, null, null, null, null);
}
private ActivityLogResponse toResponse(ActivityLog entry) {
ActivityLogResponse response = new ActivityLogResponse();
response.setLogId(entry.getLogId());
if (entry.getUser() != null) {
response.setUserId(entry.getUser().getId());
response.setUsername(firstNonBlank(entry.getUsernameSnapshot(), entry.getUser().getUsername()));
response.setFullName(firstNonBlank(entry.getFullNameSnapshot(), resolveFullName(entry.getUser())));
response.setRole(firstNonBlank(entry.getRoleSnapshot(), entry.getUser().getRole() != null ? entry.getUser().getRole().name() : null));
}
StoreLocation store = entry.getStore();
if (store != null) {
response.setStoreId(store.getStoreId());
response.setStoreName(firstNonBlank(entry.getStoreNameSnapshot(), store.getStoreName()));
}
response.setUsernameSnapshot(entry.getUsernameSnapshot());
response.setFullNameSnapshot(entry.getFullNameSnapshot());
response.setRoleSnapshot(entry.getRoleSnapshot());
response.setStoreNameSnapshot(entry.getStoreNameSnapshot());
response.setActivity(entry.getActivity());
response.setLogTimestamp(entry.getLogTimestamp());
return response;
}
private String resolveFullName(User user) {
if (user == null) {
return null;
}
if (user.getFullName() != null && !user.getFullName().isBlank()) {
return user.getFullName();
}
String first = user.getFirstName();
String last = user.getLastName();
if (first == null || first.isBlank()) {
return last;
}
if (last == null || last.isBlank()) {
return first;
}
return first.trim() + " " + last.trim();
}
private String firstNonBlank(String preferred, String fallback) {
if (preferred != null && !preferred.isBlank()) {
return preferred;
}
return fallback;
}
} }

View File

@@ -127,8 +127,6 @@ public class AppointmentService {
} }
} }
validateSpeciesServiceCompatibility(pet, service);
validateStoreAccess(store.getStoreId(), authenticatedUser); validateStoreAccess(store.getStoreId(), authenticatedUser);
validatePetServiceCompatibility(pet, service); validatePetServiceCompatibility(pet, service);
validateAvailability(employee, service, request.getAppointmentDate(), request.getAppointmentTime(), null); validateAvailability(employee, service, request.getAppointmentDate(), request.getAppointmentTime(), null);
@@ -398,32 +396,6 @@ public class AppointmentService {
} }
} }
private void validateSpeciesServiceCompatibility(Pet pet, com.petshop.backend.entity.Service service) {
if (pet == null || service == null) return;
String species = pet.getPetSpecies();
if (species == null) return;
String serviceName = service.getServiceName().toLowerCase();
switch (species.toLowerCase()) {
case "bird":
if (!serviceName.contains("wing clipping") && !serviceName.contains("beak and nail")) {
throw new IllegalArgumentException(
"Service '" + service.getServiceName() + "' is not available for birds. " +
"Allowed services: Wing Clipping, Beak and Nail Care.");
}
break;
case "fish":
if (!serviceName.contains("aquarium health")) {
throw new IllegalArgumentException(
"Service '" + service.getServiceName() + "' is not available for fish. " +
"Allowed service: Aquarium Health Check.");
}
break;
default:
break;
}
}
private void validateStoreAccess(Long requestedStoreId, User user) { private void validateStoreAccess(Long requestedStoreId, User user) {
if (user.getRole() != User.Role.STAFF) { if (user.getRole() != User.Role.STAFF) {
return; return;

View File

@@ -59,14 +59,20 @@ public class ChatService {
conversation.setMode(Conversation.ConversationMode.AUTOMATED); conversation.setMode(Conversation.ConversationMode.AUTOMATED);
conversation = conversationRepository.save(conversation); conversation = conversationRepository.save(conversation);
Message message = new Message(); User botUser = getBotUser();
message.setConversationId(conversation.getId()); String firstName = user.getFirstName();
message.setSenderId(userId); String greeting = (firstName != null && !firstName.isBlank())
message.setContent(request.getMessage()); ? "Hi " + firstName + "! I'm Leon's Pet Assistant. Ask me anything about pet care, adoption advice, or your pets."
message.setIsRead(false); : "Hi! I'm Leon's Pet Assistant. Ask me anything about pet care, adoption advice, or your pets.";
messageRepository.save(message);
return ConversationResponse.fromEntity(conversation, request.getMessage(), userId); Message greetingMsg = new Message();
greetingMsg.setConversationId(conversation.getId());
greetingMsg.setSenderId(botUser.getId());
greetingMsg.setContent(greeting);
greetingMsg.setIsRead(false);
messageRepository.save(greetingMsg);
return ConversationResponse.fromEntity(conversation, greeting, botUser.getId());
} }
public List<ConversationResponse> getConversations(Long userId, User.Role role, boolean mine) { public List<ConversationResponse> getConversations(Long userId, User.Role role, boolean mine) {

View File

@@ -5,8 +5,10 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper; import com.fasterxml.jackson.databind.json.JsonMapper;
import com.petshop.backend.entity.Conversation; import com.petshop.backend.entity.Conversation;
import com.petshop.backend.entity.Message; import com.petshop.backend.entity.Message;
import com.petshop.backend.entity.Pet;
import com.petshop.backend.entity.User; import com.petshop.backend.entity.User;
import com.petshop.backend.repository.MessageRepository; import com.petshop.backend.repository.MessageRepository;
import com.petshop.backend.repository.PetRepository;
import com.petshop.backend.repository.UserRepository; import com.petshop.backend.repository.UserRepository;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -31,7 +33,7 @@ public class OpenRouterAiService {
@Value("${openrouter.api-key:}") @Value("${openrouter.api-key:}")
private String apiKey; private String apiKey;
@Value("${openrouter.model:openai/gpt-oss-120b:free}") @Value("${openrouter.model:google/gemma-4-31b-it:free}")
private String model; private String model;
private final String openRouterUrl = "https://openrouter.ai/api/v1/chat/completions"; private final String openRouterUrl = "https://openrouter.ai/api/v1/chat/completions";
@@ -39,6 +41,7 @@ public class OpenRouterAiService {
private final ChatRealtimeService chatRealtimeService; private final ChatRealtimeService chatRealtimeService;
private final MessageRepository messageRepository; private final MessageRepository messageRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
private final PetRepository petRepository;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final HttpClient httpClient; private final HttpClient httpClient;
@@ -46,12 +49,14 @@ public class OpenRouterAiService {
ChatService chatService, ChatService chatService,
ChatRealtimeService chatRealtimeService, ChatRealtimeService chatRealtimeService,
MessageRepository messageRepository, MessageRepository messageRepository,
UserRepository userRepository UserRepository userRepository,
PetRepository petRepository
) { ) {
this.chatService = chatService; this.chatService = chatService;
this.chatRealtimeService = chatRealtimeService; this.chatRealtimeService = chatRealtimeService;
this.messageRepository = messageRepository; this.messageRepository = messageRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
this.petRepository = petRepository;
this.objectMapper = JsonMapper.builder().findAndAddModules().build(); this.objectMapper = JsonMapper.builder().findAndAddModules().build();
this.httpClient = HttpClient.newBuilder() this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10)) .connectTimeout(Duration.ofSeconds(10))
@@ -117,10 +122,15 @@ public class OpenRouterAiService {
return; return;
} }
User customer = userRepository.findById(conversation.getCustomerId()).orElse(null);
List<Pet> customerPets = customer != null
? petRepository.findAllByOwner_IdOrderByPetNameAsc(customer.getId())
: List.of();
List<Map<String, String>> messages = new ArrayList<>(); List<Map<String, String>> messages = new ArrayList<>();
messages.add(Map.of( messages.add(Map.of(
"role", "system", "role", "system",
"content", "You are a helpful pet shop assistant. Provide concise and friendly answers. Do not output markdown, just plain text." "content", buildSystemPrompt(customer, customerPets)
)); ));
for (Message message : history) { for (Message message : history) {
@@ -177,6 +187,43 @@ public class OpenRouterAiService {
} }
} }
private String buildSystemPrompt(User customer, List<Pet> pets) {
StringBuilder sb = new StringBuilder();
sb.append("You are Leon's Pet Assistant, a helpful AI for Leon's Pet Store. ");
sb.append("Be concise, friendly, and focused on pet care, adoption, products, and appointments. ");
sb.append("Do not output markdown, just plain text.\n\n");
if (customer != null) {
sb.append("Customer profile:\n");
sb.append("- Name: ").append(customer.getFirstName()).append(" ").append(customer.getLastName()).append("\n");
if (customer.getLoyaltyPoints() != null) {
sb.append("- Loyalty points: ").append(customer.getLoyaltyPoints()).append("\n");
}
if (customer.getPrimaryStore() != null && customer.getPrimaryStore().getStoreName() != null) {
sb.append("- Preferred store: ").append(customer.getPrimaryStore().getStoreName()).append("\n");
}
sb.append("\n");
}
if (pets != null && !pets.isEmpty()) {
sb.append("Their registered pets:\n");
for (Pet pet : pets) {
sb.append("- ").append(pet.getPetName()).append(" (").append(pet.getPetSpecies());
if (pet.getPetBreed() != null && !pet.getPetBreed().isBlank()) {
sb.append(", ").append(pet.getPetBreed());
}
if (pet.getPetAge() != null) {
sb.append(", ").append(pet.getPetAge()).append(" yr");
}
sb.append(")\n");
}
} else {
sb.append("They have no pets registered yet.\n");
}
return sb.toString();
}
private String resolveRole(Message message, Long botUserId) { private String resolveRole(Message message, Long botUserId) {
if (message.getSenderId() != null && message.getSenderId().equals(botUserId)) { if (message.getSenderId() != null && message.getSenderId().equals(botUserId)) {
return "assistant"; return "assistant";

View File

@@ -24,7 +24,7 @@ public class OpenRouterService {
@Value("${openrouter.api-key:}") @Value("${openrouter.api-key:}")
private String apiKey; private String apiKey;
@Value("${openrouter.model:meta-llama/llama-3.3-70b-instruct:free}") @Value("${openrouter.model:google/gemma-4-31b-it:free}")
private String model; private String model;
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();

View File

@@ -7,6 +7,7 @@ import com.petshop.backend.entity.User;
import com.petshop.backend.exception.BusinessException; import com.petshop.backend.exception.BusinessException;
import com.petshop.backend.repository.PasswordResetTokenRepository; import com.petshop.backend.repository.PasswordResetTokenRepository;
import com.petshop.backend.repository.UserRepository; import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.security.UserAuthCacheService;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -29,16 +30,19 @@ public class PasswordResetService {
private final UserRepository userRepository; private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final EmailService emailService; private final EmailService emailService;
private final UserAuthCacheService userAuthCacheService;
private final SecureRandom secureRandom = new SecureRandom(); private final SecureRandom secureRandom = new SecureRandom();
public PasswordResetService(PasswordResetTokenRepository passwordResetTokenRepository, public PasswordResetService(PasswordResetTokenRepository passwordResetTokenRepository,
UserRepository userRepository, UserRepository userRepository,
PasswordEncoder passwordEncoder, PasswordEncoder passwordEncoder,
EmailService emailService) { EmailService emailService,
UserAuthCacheService userAuthCacheService) {
this.passwordResetTokenRepository = passwordResetTokenRepository; this.passwordResetTokenRepository = passwordResetTokenRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.emailService = emailService; this.emailService = emailService;
this.userAuthCacheService = userAuthCacheService;
} }
@Transactional @Transactional
@@ -97,6 +101,7 @@ public class PasswordResetService {
user.setPassword(passwordEncoder.encode(newPassword)); user.setPassword(passwordEncoder.encode(newPassword));
user.setTokenVersion(user.getTokenVersion() + 1); user.setTokenVersion(user.getTokenVersion() + 1);
userRepository.save(user); userRepository.save(user);
userAuthCacheService.evict(user.getId());
token.setUsedAt(now); token.setUsedAt(now);
passwordResetTokenRepository.save(token); passwordResetTokenRepository.save(token);

View File

@@ -8,6 +8,7 @@ import com.petshop.backend.entity.User;
import com.petshop.backend.exception.ResourceNotFoundException; import com.petshop.backend.exception.ResourceNotFoundException;
import com.petshop.backend.repository.StoreRepository; import com.petshop.backend.repository.StoreRepository;
import com.petshop.backend.repository.UserRepository; import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.security.UserAuthCacheService;
import com.petshop.backend.util.AuthenticationHelper; import com.petshop.backend.util.AuthenticationHelper;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
@@ -33,11 +34,13 @@ public class UserService {
private final UserRepository userRepository; private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final StoreRepository storeRepository; private final StoreRepository storeRepository;
private final UserAuthCacheService userAuthCacheService;
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder, StoreRepository storeRepository) { public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder, StoreRepository storeRepository, UserAuthCacheService userAuthCacheService) {
this.userRepository = userRepository; this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.storeRepository = storeRepository; this.storeRepository = storeRepository;
this.userAuthCacheService = userAuthCacheService;
} }
public Page<UserResponse> getAllUsers(String query, String role, Pageable pageable) { public Page<UserResponse> getAllUsers(String query, String role, Pageable pageable) {
@@ -147,6 +150,7 @@ public class UserService {
} }
user = userRepository.save(user); user = userRepository.save(user);
userAuthCacheService.evict(user.getId());
return mapToResponse(user); return mapToResponse(user);
} }

View File

@@ -18,6 +18,10 @@ spring:
username: ${SPRING_DATASOURCE_USERNAME:petshop} username: ${SPRING_DATASOURCE_USERNAME:petshop}
password: ${SPRING_DATASOURCE_PASSWORD:petshop} password: ${SPRING_DATASOURCE_PASSWORD:petshop}
driver-class-name: com.mysql.cj.jdbc.Driver driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
sql: sql:
init: init:
@@ -46,14 +50,16 @@ server:
springdoc: springdoc:
api-docs: api-docs:
path: /v3/api-docs path: /v3/api-docs
enabled: ${SWAGGER_ENABLED:false}
swagger-ui: swagger-ui:
path: /swagger-ui path: /swagger-ui
enabled: ${SWAGGER_ENABLED:false}
app: app:
upload: upload:
base-dir: ${UPLOAD_BASE_DIR:uploads} base-dir: ${UPLOAD_BASE_DIR:uploads}
frontend-url: ${FRONTEND_URL:http://localhost:3000} frontend-url: ${FRONTEND_URL:http://localhost:3000}
allowed-origins: ${ALLOWED_ORIGINS:http://localhost:3000,http://localhost:3001,http://127.0.0.1:3000} allowed-origins: ${ALLOWED_ORIGINS:http://localhost:3000,http://localhost:3001,http://127.0.0.1:3000,https://petshop-web.nicepond-c7280126.westus2.azurecontainerapps.io}
azure: azure:
storage: storage:
@@ -82,9 +88,3 @@ logging:
com.petshop: ${LOG_LEVEL:INFO} com.petshop: ${LOG_LEVEL:INFO}
org.springframework.security: ${LOG_LEVEL_SECURITY:WARN} org.springframework.security: ${LOG_LEVEL_SECURITY:WARN}
org.springdoc.core.events.SpringDocAppInitializer: ERROR org.springdoc.core.events.SpringDocAppInitializer: ERROR
jackson:
serialization:
write-dates-as-timestamps: false
deserialization:
fail-on-unknown-properties: false

View File

@@ -0,0 +1,14 @@
DELETE FROM service_species WHERE serviceId = 2 AND species = 'Bird';
INSERT INTO service_species (serviceId, species) VALUES
(1, 'Guinea Pig'),
(1, 'Hamster'),
(1, 'Other'),
(2, 'Reptile'),
(2, 'Other'),
(3, 'Reptile'),
(3, 'Other'),
(4, 'Reptile'),
(4, 'Other'),
(5, 'Reptile'),
(5, 'Other');

View File

@@ -0,0 +1,90 @@
INSERT INTO activityLog (userId, storeId, usernameSnapshot, fullNameSnapshot, roleSnapshot, storeNameSnapshot, activity, logTimestamp) VALUES
(1, 1, 'admin', 'Admin User', 'ADMIN', 'Downtown Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-01-15 08:02:11'),
(1, 1, 'admin', 'Admin User', 'ADMIN', 'Downtown Branch', 'Created a new pet | POST /api/v1/pets → 201', '2026-01-15 08:15:44'),
(1, 1, 'admin', 'Admin User', 'ADMIN', 'Downtown Branch', 'Created a new product | POST /api/v1/products → 201', '2026-01-15 08:31:07'),
(3, 1, 'staff', 'Staff User', 'STAFF', 'Downtown Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-01-15 09:00:00'),
(4, 1, 'sara.smith', 'Sara Smith', 'STAFF', 'Downtown Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-01-15 09:04:22'),
(15, NULL,'customer', 'Test Customer', 'CUSTOMER', NULL, 'Logged in | POST /api/v1/auth/login → 200', '2026-01-15 10:12:33'),
(15, NULL,'customer', 'Test Customer', 'CUSTOMER', NULL, 'Added an item to cart | POST /api/v1/cart/add → 200', '2026-01-15 10:18:05'),
(15, NULL,'customer', 'Test Customer', 'CUSTOMER', NULL, 'Completed a purchase | POST /api/v1/cart/checkout/complete → 200', '2026-01-15 10:25:50'),
(4, 1, 'sara.smith', 'Sara Smith', 'STAFF', 'Downtown Branch', 'Updated appointment #12 | PUT /api/v1/appointments/12 → 200', '2026-01-16 11:05:30'),
(1, 1, 'admin', 'Admin User', 'ADMIN', 'Downtown Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-01-17 07:58:44'),
(1, 1, 'admin', 'Admin User', 'ADMIN', 'Downtown Branch', 'Updated pet #8 | PUT /api/v1/pets/8 → 200', '2026-01-17 08:10:19'),
(7, 2, 'michael.johnson', 'Michael Johnson', 'STAFF', 'North Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-01-20 09:01:55'),
(16, NULL,'alex.brown', 'Alex Brown', 'CUSTOMER', NULL, 'Logged in | POST /api/v1/auth/login → 200', '2026-01-20 14:30:22'),
(16, NULL,'alex.brown', 'Alex Brown', 'CUSTOMER', NULL, 'Submitted an adoption request | POST /api/v1/adoptions/request → 201', '2026-01-20 14:45:08'),
(5, 1, 'david.brown', 'David Brown', 'STAFF', 'Downtown Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-01-22 08:55:00'),
(5, 1, 'david.brown', 'David Brown', 'STAFF', 'Downtown Branch', 'Updated pet #14 | PUT /api/v1/pets/14 → 200', '2026-01-22 09:20:17'),
(2, 2, 'morgan.lee', 'Morgan Lee', 'ADMIN', 'North Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-01-25 08:00:00'),
(2, 2, 'morgan.lee', 'Morgan Lee', 'ADMIN', 'North Branch', 'Created a new service | POST /api/v1/services → 201', '2026-01-25 08:22:41'),
(2, 2, 'morgan.lee', 'Morgan Lee', 'ADMIN', 'North Branch', 'Created a new employee | POST /api/v1/employees → 201', '2026-01-25 09:05:14'),
(17, NULL,'alex.clark', 'Alex Clark', 'CUSTOMER', NULL, 'Logged in | POST /api/v1/auth/login → 200', '2026-01-28 18:30:00'),
(17, NULL,'alex.clark', 'Alex Clark', 'CUSTOMER', NULL, 'Sent a message to the AI assistant | POST /api/v1/ai-chat/message → 200','2026-01-28 18:35:12'),
(17, NULL,'alex.clark', 'Alex Clark', 'CUSTOMER', NULL, 'Updated their profile | PUT /api/v1/auth/me → 200', '2026-01-28 18:40:55'),
(8, 2, 'emma.davis', 'Emma Davis', 'STAFF', 'North Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-02-03 09:00:00'),
(8, 2, 'emma.davis', 'Emma Davis', 'STAFF', 'North Branch', 'Completed a purchase | POST /api/v1/cart/checkout/complete → 200', '2026-02-03 10:15:38'),
(1, 1, 'admin', 'Admin User', 'ADMIN', 'Downtown Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-02-05 07:50:00'),
(1, 1, 'admin', 'Admin User', 'ADMIN', 'Downtown Branch', 'Deleted pet #3 | DELETE /api/v1/pets/3 → 200', '2026-02-05 08:05:33'),
(1, 1, 'admin', 'Admin User', 'ADMIN', 'Downtown Branch', 'Updated product #22 | PUT /api/v1/products/22 → 200', '2026-02-05 08:30:44'),
(18, NULL,'alex.wilson', 'Alex Wilson', 'CUSTOMER', NULL, 'Logged in | POST /api/v1/auth/login → 200', '2026-02-07 12:00:00'),
(18, NULL,'alex.wilson', 'Alex Wilson', 'CUSTOMER', NULL, 'Added an item to cart | POST /api/v1/cart/add → 200', '2026-02-07 12:08:17'),
(18, NULL,'alex.wilson', 'Alex Wilson', 'CUSTOMER', NULL, 'Applied a coupon to cart | POST /api/v1/cart/apply-coupon → 200', '2026-02-07 12:12:05'),
(18, NULL,'alex.wilson', 'Alex Wilson', 'CUSTOMER', NULL, 'Completed a purchase | POST /api/v1/cart/checkout/complete → 200', '2026-02-07 12:20:30'),
(11, 3, 'lisa.williams', 'Lisa Williams', 'STAFF', 'West Side Store', 'Logged in | POST /api/v1/auth/login → 200', '2026-02-10 09:00:00'),
(11, 3, 'lisa.williams', 'Lisa Williams', 'STAFF', 'West Side Store', 'Created a new appointment | POST /api/v1/appointments → 201', '2026-02-10 09:30:22'),
(11, 3, 'lisa.williams', 'Lisa Williams', 'STAFF', 'West Side Store', 'Updated appointment #25 | PUT /api/v1/appointments/25 → 200', '2026-02-10 11:45:09'),
(19, NULL,'alex.martinez', 'Alex Martinez', 'CUSTOMER', NULL, 'Logged in | POST /api/v1/auth/login → 200', '2026-02-14 15:00:00'),
(19, NULL,'alex.martinez', 'Alex Martinez', 'CUSTOMER', NULL, 'Started a new chat conversation | POST /api/v1/chat/conversations → 201','2026-02-14 15:05:44'),
(19, NULL,'alex.martinez', 'Alex Martinez', 'CUSTOMER', NULL, 'Sent a chat message | POST /api/v1/chat/conversations/5/messages → 201', '2026-02-14 15:08:22'),
(3, 1, 'staff', 'Staff User', 'STAFF', 'Downtown Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-02-17 09:00:00'),
(3, 1, 'staff', 'Staff User', 'STAFF', 'Downtown Branch', 'Uploaded image for pet #31 | POST /api/v1/pets/31/image → 200', '2026-02-17 09:25:11'),
(1, 1, 'admin', 'Admin User', 'ADMIN', 'Downtown Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-02-20 08:00:00'),
(1, 1, 'admin', 'Admin User', 'ADMIN', 'Downtown Branch', 'Created a new pet | POST /api/v1/pets → 201', '2026-02-20 08:20:35'),
(1, 1, 'admin', 'Admin User', 'ADMIN', 'Downtown Branch', 'Updated user #18 | PUT /api/v1/users/18 → 200', '2026-02-20 09:10:00'),
(20, NULL,'alex.anderson', 'Alex Anderson', 'CUSTOMER', NULL, 'Logged in | POST /api/v1/auth/login → 200', '2026-02-22 19:00:00'),
(20, NULL,'alex.anderson', 'Alex Anderson', 'CUSTOMER', NULL, 'Submitted an adoption request | POST /api/v1/adoptions/request → 201', '2026-02-22 19:15:40'),
(7, 2, 'michael.johnson', 'Michael Johnson', 'STAFF', 'North Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-03-01 09:00:00'),
(7, 2, 'michael.johnson', 'Michael Johnson', 'STAFF', 'North Branch', 'Completed a purchase | POST /api/v1/cart/checkout/complete → 200', '2026-03-01 10:30:15'),
(7, 2, 'michael.johnson', 'Michael Johnson', 'STAFF', 'North Branch', 'Updated appointment #33 | PUT /api/v1/appointments/33 → 200', '2026-03-01 14:05:22'),
(21, NULL,'alex.taylor', 'Alex Taylor', 'CUSTOMER', NULL, 'Logged in | POST /api/v1/auth/login → 200', '2026-03-05 11:00:00'),
(21, NULL,'alex.taylor', 'Alex Taylor', 'CUSTOMER', NULL, 'Added an item to cart | POST /api/v1/cart/add → 200', '2026-03-05 11:10:30'),
(21, NULL,'alex.taylor', 'Alex Taylor', 'CUSTOMER', NULL, 'Started checkout | POST /api/v1/cart/checkout → 200', '2026-03-05 11:18:44'),
(21, NULL,'alex.taylor', 'Alex Taylor', 'CUSTOMER', NULL, 'Completed a purchase | POST /api/v1/cart/checkout/complete → 200', '2026-03-05 11:22:17'),
(2, 2, 'morgan.lee', 'Morgan Lee', 'ADMIN', 'North Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-03-08 08:00:00'),
(2, 2, 'morgan.lee', 'Morgan Lee', 'ADMIN', 'North Branch', 'Deleted multiple pets | POST /api/v1/pets/bulk-delete → 200', '2026-03-08 08:30:00'),
(4, 1, 'sara.smith', 'Sara Smith', 'STAFF', 'Downtown Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-03-10 09:00:00'),
(4, 1, 'sara.smith', 'Sara Smith', 'STAFF', 'Downtown Branch', 'Completed a purchase | POST /api/v1/cart/checkout/complete → 200', '2026-03-10 10:45:33'),
(22, NULL,'alex.parker', 'Alex Parker', 'CUSTOMER', NULL, 'Logged in | POST /api/v1/auth/login → 200', '2026-03-12 16:00:00'),
(22, NULL,'alex.parker', 'Alex Parker', 'CUSTOMER', NULL, 'Sent a message to the AI assistant | POST /api/v1/ai-chat/message → 200','2026-03-12 16:05:22'),
(22, NULL,'alex.parker', 'Alex Parker', 'CUSTOMER', NULL, 'Updated their profile | PUT /api/v1/auth/me → 200', '2026-03-12 16:20:00'),
(1, 1, 'admin', 'Admin User', 'ADMIN', 'Downtown Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-03-15 07:55:00'),
(1, 1, 'admin', 'Admin User', 'ADMIN', 'Downtown Branch', 'Created a new product | POST /api/v1/products → 201', '2026-03-15 08:10:45'),
(1, 1, 'admin', 'Admin User', 'ADMIN', 'Downtown Branch', 'Updated product #35 | PUT /api/v1/products/35 → 200', '2026-03-15 08:40:12'),
(5, 1, 'david.brown', 'David Brown', 'STAFF', 'Downtown Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-03-18 09:00:00'),
(5, 1, 'david.brown', 'David Brown', 'STAFF', 'Downtown Branch', 'Updated appointment #45 | PUT /api/v1/appointments/45 → 200', '2026-03-18 09:35:08'),
(23, NULL,'alex.evans', 'Alex Evans', 'CUSTOMER', NULL, 'Logged in | POST /api/v1/auth/login → 200', '2026-03-20 20:00:00'),
(23, NULL,'alex.evans', 'Alex Evans', 'CUSTOMER', NULL, 'Started a new chat conversation | POST /api/v1/chat/conversations → 201','2026-03-20 20:05:30'),
(23, NULL,'alex.evans', 'Alex Evans', 'CUSTOMER', NULL, 'Sent a chat message | POST /api/v1/chat/conversations/9/messages → 201', '2026-03-20 20:08:11'),
(11, 3, 'lisa.williams', 'Lisa Williams', 'STAFF', 'West Side Store', 'Logged in | POST /api/v1/auth/login → 200', '2026-03-24 09:00:00'),
(11, 3, 'lisa.williams', 'Lisa Williams', 'STAFF', 'West Side Store', 'Created a new appointment | POST /api/v1/appointments → 201', '2026-03-24 09:20:44'),
(8, 2, 'emma.davis', 'Emma Davis', 'STAFF', 'North Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-03-27 09:00:00'),
(8, 2, 'emma.davis', 'Emma Davis', 'STAFF', 'North Branch', 'Updated appointment #52 | PUT /api/v1/appointments/52 → 200', '2026-03-27 10:10:19'),
(8, 2, 'emma.davis', 'Emma Davis', 'STAFF', 'North Branch', 'Completed a purchase | POST /api/v1/cart/checkout/complete → 200', '2026-03-27 11:30:00'),
(1, 1, 'admin', 'Admin User', 'ADMIN', 'Downtown Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-04-01 08:00:00'),
(1, 1, 'admin', 'Admin User', 'ADMIN', 'Downtown Branch', 'Created a new pet | POST /api/v1/pets → 201', '2026-04-01 08:15:00'),
(1, 1, 'admin', 'Admin User', 'ADMIN', 'Downtown Branch', 'Updated user #25 | PUT /api/v1/users/25 → 200', '2026-04-01 09:00:22'),
(15, NULL,'customer', 'Test Customer', 'CUSTOMER', NULL, 'Logged in | POST /api/v1/auth/login → 200', '2026-04-05 10:00:00'),
(15, NULL,'customer', 'Test Customer', 'CUSTOMER', NULL, 'Added an item to cart | POST /api/v1/cart/add → 200', '2026-04-05 10:15:00'),
(15, NULL,'customer', 'Test Customer', 'CUSTOMER', NULL, 'Completed a purchase | POST /api/v1/cart/checkout/complete → 200', '2026-04-05 10:25:44'),
(3, 1, 'staff', 'Staff User', 'STAFF', 'Downtown Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-04-07 09:00:00'),
(3, 1, 'staff', 'Staff User', 'STAFF', 'Downtown Branch', 'Updated pet #47 | PUT /api/v1/pets/47 → 200', '2026-04-07 09:40:55'),
(24, NULL,'alex.scott', 'Alex Scott', 'CUSTOMER', NULL, 'Logged in | POST /api/v1/auth/login → 200', '2026-04-09 17:00:00'),
(24, NULL,'alex.scott', 'Alex Scott', 'CUSTOMER', NULL, 'Submitted an adoption request | POST /api/v1/adoptions/request → 201', '2026-04-09 17:20:33'),
(2, 2, 'morgan.lee', 'Morgan Lee', 'ADMIN', 'North Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-04-12 08:00:00'),
(2, 2, 'morgan.lee', 'Morgan Lee', 'ADMIN', 'North Branch', 'Created a new service | POST /api/v1/services → 201', '2026-04-12 08:25:18'),
(4, 1, 'sara.smith', 'Sara Smith', 'STAFF', 'Downtown Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-04-14 09:00:00'),
(4, 1, 'sara.smith', 'Sara Smith', 'STAFF', 'Downtown Branch', 'Completed a purchase | POST /api/v1/cart/checkout/complete → 200', '2026-04-14 10:55:30'),
(4, 1, 'sara.smith', 'Sara Smith', 'STAFF', 'Downtown Branch', 'Updated appointment #60 | PUT /api/v1/appointments/60 → 200', '2026-04-14 14:20:00'),
(25, NULL,'alex.adams', 'Alex Adams', 'CUSTOMER', NULL, 'Logged in | POST /api/v1/auth/login → 200', '2026-04-15 11:00:00'),
(25, NULL,'alex.adams', 'Alex Adams', 'CUSTOMER', NULL, 'Added an item to cart | POST /api/v1/cart/add → 200', '2026-04-15 11:10:22'),
(25, NULL,'alex.adams', 'Alex Adams', 'CUSTOMER', NULL, 'Sent a message to the AI assistant | POST /api/v1/ai-chat/message → 200','2026-04-15 11:30:00');

View File

@@ -0,0 +1,51 @@
INSERT IGNORE INTO sale (saleId, saleDate, totalAmount, paymentMethod, employeeId, storeId, customerId, isRefund, originalSaleId, channel, cartId, couponId, subtotalAmount, couponDiscountAmount, employeeDiscountAmount, pointsEarned) VALUES
(134, '2026-04-10 09:15:00', 57.67, 'Cash', 4, 1, 16, 0, NULL, 'IN_STORE', NULL, NULL, 57.67, 0.00, 0.00, 5),
(135, '2026-04-10 11:30:00', 143.55, 'Card', 8, 2, 17, 0, NULL, 'ONLINE', NULL, NULL, 143.55, 0.00, 0.00, 14),
(136, '2026-04-10 14:45:00', 50.09, 'Cash', 12, 3, 18, 0, NULL, 'IN_STORE', NULL, NULL, 50.09, 0.00, 0.00, 5),
(137, '2026-04-11 10:00:00', 114.48, 'Card', 5, 1, 19, 0, NULL, 'ONLINE', NULL, NULL, 114.48, 0.00, 0.00, 11),
(138, '2026-04-11 13:20:00', 93.55, 'Cash', 9, 2, 20, 0, NULL, 'IN_STORE', NULL, NULL, 93.55, 0.00, 0.00, 9),
(139, '2026-04-12 09:45:00', 100.71, 'Card', 13, 3, 21, 0, NULL, 'ONLINE', NULL, NULL, 100.71, 0.00, 0.00, 10),
(140, '2026-04-12 11:00:00', 51.07, 'Cash', 6, 1, 22, 0, NULL, 'IN_STORE', NULL, NULL, 51.07, 0.00, 0.00, 5),
(141, '2026-04-12 15:30:00', 139.66, 'Card', 7, 2, 23, 0, NULL, 'ONLINE', NULL, NULL, 139.66, 0.00, 0.00, 13),
(142, '2026-04-13 09:00:00', 73.98, 'Cash', 14, 3, 24, 0, NULL, 'IN_STORE', NULL, NULL, 73.98, 0.00, 0.00, 7),
(143, '2026-04-13 12:15:00', 134.76, 'Card', 4, 1, 25, 0, NULL, 'ONLINE', NULL, NULL, 134.76, 0.00, 0.00, 13),
(144, '2026-04-14 10:30:00', 80.40, 'Cash', 10, 2, 26, 0, NULL, 'IN_STORE', NULL, NULL, 80.40, 0.00, 0.00, 8),
(145, '2026-04-14 14:00:00', 125.90, 'Card', 11, 3, 27, 0, NULL, 'ONLINE', NULL, NULL, 125.90, 0.00, 0.00, 12),
(146, '2026-04-15 10:45:00', 80.62, 'Cash', 5, 1, 28, 0, NULL, 'IN_STORE', NULL, NULL, 80.62, 0.00, 0.00, 8),
(147, '2026-04-15 13:00:00', 141.28, 'Card', 8, 2, 29, 0, NULL, 'ONLINE', NULL, NULL, 141.28, 0.00, 0.00, 14),
(148, '2026-04-16 09:30:00', 97.85, 'Cash', 12, 3, 30, 0, NULL, 'IN_STORE', NULL, NULL, 97.85, 0.00, 0.00, 9),
(149, '2026-04-16 11:45:00', 89.36, 'Card', 6, 1, 31, 0, NULL, 'ONLINE', NULL, NULL, 89.36, 0.00, 0.00, 8);
INSERT IGNORE INTO saleItem (saleItemId, saleId, prodId, quantity, unitPrice) VALUES
(264, 134, 1, 2, 25.09),
(265, 134, 11, 1, 7.49),
(266, 135, 7, 2, 57.62),
(267, 135, 25, 1, 28.31),
(268, 136, 3, 1, 35.93),
(269, 136, 14, 1, 14.16),
(270, 137, 15, 3, 16.38),
(271, 137, 26, 2, 32.67),
(272, 138, 8, 1, 63.05),
(273, 138, 22, 2, 15.25),
(274, 139, 20, 2, 27.49),
(275, 139, 29, 1, 45.73),
(276, 140, 4, 1, 41.36),
(277, 140, 12, 1, 9.71),
(278, 141, 34, 2, 59.42),
(279, 141, 17, 1, 20.82),
(280, 142, 6, 1, 52.20),
(281, 142, 21, 2, 10.89),
(282, 143, 37, 1, 97.56),
(283, 143, 16, 2, 18.60),
(284, 144, 9, 1, 68.47),
(285, 144, 13, 1, 11.93),
(286, 145, 19, 3, 25.27),
(287, 145, 30, 1, 50.09),
(288, 146, 2, 2, 30.51),
(289, 146, 23, 1, 19.60),
(290, 147, 35, 1, 72.13),
(291, 147, 18, 3, 23.05),
(292, 148, 10, 1, 73.89),
(293, 148, 24, 1, 23.96),
(294, 149, 31, 2, 21.29),
(295, 149, 5, 1, 46.78);

View File

@@ -8,6 +8,7 @@ import com.petshop.backend.entity.User;
import com.petshop.backend.repository.StoreRepository; import com.petshop.backend.repository.StoreRepository;
import com.petshop.backend.repository.UserRepository; import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.security.AppPrincipal; import com.petshop.backend.security.AppPrincipal;
import com.petshop.backend.security.UserAuthCacheService;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -38,12 +39,13 @@ class UserServiceTest {
@Mock private UserRepository userRepository; @Mock private UserRepository userRepository;
@Mock private PasswordEncoder passwordEncoder; @Mock private PasswordEncoder passwordEncoder;
@Mock private StoreRepository storeRepository; @Mock private StoreRepository storeRepository;
@Mock private UserAuthCacheService userAuthCacheService;
private UserService userService; private UserService userService;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
userService = new UserService(userRepository, passwordEncoder, storeRepository); userService = new UserService(userRepository, passwordEncoder, storeRepository, userAuthCacheService);
} }
@AfterEach @AfterEach

View File

@@ -90,8 +90,9 @@ public class ChatRealtimeClient implements WebSocket.Listener {
for (ConversationResponse conv : globalConversations.values()) { for (ConversationResponse conv : globalConversations.values()) {
if ("CLOSED".equals(conv.getStatus())) continue; if ("CLOSED".equals(conv.getStatus())) continue;
// Needs pickup // Needs pickup - only if we haven't already replied
if (conv.getHumanRequestedAt() != null && conv.getStaffId() == null) { if (conv.getHumanRequestedAt() != null && conv.getStaffId() == null
&& (currentUserId == null || !currentUserId.equals(conv.getLastSenderId()))) {
return true; return true;
} }

2
web/.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules
.next

View File

@@ -1,4 +1,5 @@
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
NEXT_PUBLIC_BACKEND_URL=http://localhost:8080
# Backend URL for the API proxy — swap comments to switch between local and remote # Backend URL for the API proxy — swap comments to switch between local and remote
BACKEND_URL=http://localhost:8080 BACKEND_URL=http://localhost:8080
#BACKEND_URL=https://petshop-backend.nicepond-c7280126.westus2.azurecontainerapps.io #BACKEND_URL=https://petshop-backend.nicepond-c7280126.westus2.azurecontainerapps.io

View File

@@ -3,8 +3,8 @@ WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci RUN npm ci
COPY . . COPY . .
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51TK18lFQ95OLlFb7dNtKXlvhry8IOvHaWJWW7zUNFhicMgyJ2EgAFhiAocxsCyP95IKt7AeQg4cWe5iHF3qoheYl0034Cd4yij
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ENV NEXT_PUBLIC_BACKEND_URL=https://petshop-backend.nicepond-c7280126.westus2.azurecontainerapps.io
RUN npm run build RUN npm run build
FROM node:22-alpine FROM node:22-alpine

View File

@@ -74,7 +74,7 @@ export default function AdoptPage() {
[pets] [pets]
); );
const ITEMS_PER_PAGE = 20; const ITEMS_PER_PAGE = 24;
const [currentPage, setCurrentPage] = useState(0); const [currentPage, setCurrentPage] = useState(0);
const filteredPets = useMemo( const filteredPets = useMemo(
@@ -192,15 +192,42 @@ export default function AdoptPage() {
<div className="pagination-controls"> <div className="pagination-controls">
<button <button
className="pagination-btn" className="pagination-btn"
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))} onClick={() => { setCurrentPage((p) => Math.max(0, p - 1)); window.scrollTo(0, 0); }}
disabled={currentPage === 0} disabled={currentPage === 0}
> >
Prev Prev
</button> </button>
<span className="pagination-info">Page {currentPage + 1} of {totalPages}</span> {(() => {
const pages = [];
const delta = 2;
const left = Math.max(0, currentPage - delta);
const right = Math.min(totalPages - 1, currentPage + delta);
if (left > 0) {
pages.push(0);
if (left > 1) pages.push("...");
}
for (let i = left; i <= right; i++) pages.push(i);
if (right < totalPages - 1) {
if (right < totalPages - 2) pages.push("...");
pages.push(totalPages - 1);
}
return pages.map((p, i) =>
p === "..." ? (
<span key={`ellipsis-${i}`} className="pagination-ellipsis"></span>
) : (
<button
key={p}
className={`pagination-btn${p === currentPage ? " pagination-btn--active" : ""}`}
onClick={() => { setCurrentPage(p); window.scrollTo(0, 0); }}
>
{p + 1}
</button>
)
);
})()}
<button <button
className="pagination-btn" className="pagination-btn"
onClick={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))} onClick={() => { setCurrentPage((p) => Math.min(totalPages - 1, p + 1)); window.scrollTo(0, 0); }}
disabled={currentPage === totalPages - 1} disabled={currentPage === totalPages - 1}
> >
Next Next

View File

@@ -4,9 +4,69 @@ import dynamic from "next/dynamic";
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
import { createStompClient } from "@/lib/chatSocket";
const API_BASE = ""; const API_BASE = "";
const POLL_INTERVAL = 2500;
function isImageFilename(name) {
return /\.(jpe?g|png|gif|webp|bmp|svg)$/i.test(name || "");
}
function AttachmentPreview({ url, name, token }) {
const [blobUrl, setBlobUrl] = useState(null);
const isImage = isImageFilename(name);
useEffect(() => {
if (!url || !token) return;
let objectUrl;
fetch(url, { headers: { Authorization: `Bearer ${token}` } })
.then((r) => (r.ok ? r.blob() : null))
.then((blob) => {
if (blob) {
objectUrl = URL.createObjectURL(blob);
setBlobUrl(objectUrl);
}
})
.catch(() => {});
return () => { if (objectUrl) URL.revokeObjectURL(objectUrl); };
}, [url, token]);
if (isImage) {
return (
<div style={{ marginTop: "0.5rem" }}>
{blobUrl ? (
<img
src={blobUrl}
alt={name || "attachment"}
style={{ maxWidth: "220px", maxHeight: "220px", borderRadius: "8px", cursor: "pointer", display: "block" }}
onClick={() => window.open(blobUrl, "_blank")}
/>
) : (
<span style={{ fontSize: "0.8rem", opacity: 0.7 }}>📎 Loading image</span>
)}
</div>
);
}
return (
<div style={{ marginTop: "0.4rem" }}>
<a
href="#"
style={{ color: "inherit", fontSize: "0.85rem", opacity: 0.85 }}
onClick={(e) => {
e.preventDefault();
if (!blobUrl) return;
const a = document.createElement("a");
a.href = blobUrl;
a.download = name || "attachment";
a.click();
}}
>
📎 {name || "Attachment"}
</a>
</div>
);
}
function AiChatPage() { function AiChatPage() {
const { user, token, loading: authLoading } = useAuth(); const { user, token, loading: authLoading } = useAuth();
@@ -22,15 +82,20 @@ function AiChatPage() {
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [conversations, setConversations] = useState([]); const [conversations, setConversations] = useState([]);
const [convsLoading, setConvsLoading] = useState(false); const [convsLoading, setConvsLoading] = useState(false);
const [closedExpanded, setClosedExpanded] = useState(false);
const [selectedFile, setSelectedFile] = useState(null); const [selectedFile, setSelectedFile] = useState(null);
const [botTyping, setBotTyping] = useState(false);
const [switchingConv, setSwitchingConv] = useState(false);
const messagesEndRef = useRef(null); const messagesEndRef = useRef(null);
const messagesAreaRef = useRef(null); const messagesAreaRef = useRef(null);
const inputRef = useRef(null); const inputRef = useRef(null);
const pollRef = useRef(null); const stompRef = useRef(null);
const lastMessageIdRef = useRef(null); const lastMessageIdRef = useRef(null);
const fileInputRef = useRef(null); const fileInputRef = useRef(null);
const lastScrolledIdRef = useRef(null); const lastScrolledIdRef = useRef(null);
const initialLoadDoneRef = useRef(false);
const botTypingTimeoutRef = useRef(null);
useEffect(() => { useEffect(() => {
if (!authLoading && !user) { if (!authLoading && !user) {
@@ -42,6 +107,7 @@ function AiChatPage() {
if (messages.length === 0) return; if (messages.length === 0) return;
const lastMsg = messages[messages.length - 1]; const lastMsg = messages[messages.length - 1];
if (lastMsg.id === lastScrolledIdRef.current) return; if (lastMsg.id === lastScrolledIdRef.current) return;
if (!initialLoadDoneRef.current) return;
lastScrolledIdRef.current = lastMsg.id; lastScrolledIdRef.current = lastMsg.id;
const area = messagesAreaRef.current; const area = messagesAreaRef.current;
if (!area) return; if (!area) return;
@@ -51,8 +117,17 @@ function AiChatPage() {
} }
}, [messages]); }, [messages]);
useEffect(() => {
if (!botTyping) return;
const area = messagesAreaRef.current;
if (!area) return;
const nearBottom = area.scrollHeight - area.scrollTop - area.clientHeight < 80;
if (nearBottom) area.scrollTop = area.scrollHeight;
}, [botTyping]);
const fetchMessages = useCallback(async (convId) => { const fetchMessages = useCallback(async (convId) => {
if (!token || !convId) return; if (!token || !convId) return;
initialLoadDoneRef.current = false;
try { try {
const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, { const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
@@ -61,10 +136,17 @@ function AiChatPage() {
const data = await res.json(); const data = await res.json();
if (Array.isArray(data)) { if (Array.isArray(data)) {
setMessages(data); setMessages(data);
if (data.length > 0) lastMessageIdRef.current = data[data.length - 1].id; if (data.length > 0) {
lastMessageIdRef.current = data[data.length - 1].id;
lastScrolledIdRef.current = data[data.length - 1].id;
}
setTimeout(() => {
const area = messagesAreaRef.current;
if (area) area.scrollTop = area.scrollHeight;
initialLoadDoneRef.current = true;
}, 50);
} }
} catch { } catch {
// silent
} }
}, [token]); }, [token]);
@@ -100,38 +182,36 @@ function AiChatPage() {
} }
}, [token]); }, [token]);
const startPolling = useCallback((convId) => { const connectStomp = useCallback((convId) => {
if (pollRef.current) clearInterval(pollRef.current); if (stompRef.current) {
pollRef.current = setInterval(async () => { stompRef.current.deactivate();
if (!token || !convId) return; stompRef.current = null;
try { }
const [msgsRes, convRes] = await Promise.all([ const client = createStompClient(token);
fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, { client.onConnect = () => {
headers: { Authorization: `Bearer ${token}` }, client.subscribe(`/topic/chat/conversations/${convId}`, (frame) => {
}), try {
fetch(`${API_BASE}/api/v1/chat/conversations/${convId}`, { const msg = JSON.parse(frame.body);
headers: { Authorization: `Bearer ${token}` }, setMessages((prev) => prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]);
}), lastMessageIdRef.current = msg.id;
]); if (botTypingTimeoutRef.current) clearTimeout(botTypingTimeoutRef.current);
if (msgsRes.ok) { setBotTyping(false);
const data = await msgsRes.json(); } catch { /* silent */ }
if (Array.isArray(data)) { });
const lastId = data.length > 0 ? data[data.length - 1].id : null; const convTopic = user?.role === "CUSTOMER"
if (lastId !== lastMessageIdRef.current) { ? `/user/queue/chat/conversations`
lastMessageIdRef.current = lastId; : `/topic/chat/conversations`;
setMessages(data); client.subscribe(convTopic, (frame) => {
} try {
} const conv = JSON.parse(frame.body);
} if (conv.id === convId) setConversation(conv);
if (convRes.ok) { setConversations((prev) => prev.map((c) => c.id === conv.id ? conv : c));
const convData = await convRes.json(); } catch { /* silent */ }
setConversation(convData); });
} };
} catch { stompRef.current = client;
// silent client.activate();
} }, [token, user?.role]);
}, POLL_INTERVAL);
}, [token]);
useEffect(() => { useEffect(() => {
if (!token || authLoading) return; if (!token || authLoading) return;
@@ -171,7 +251,7 @@ function AiChatPage() {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
body: JSON.stringify({ message: "Hello! I'd like to chat with the AI assistant." }), body: JSON.stringify({}),
}); });
if (stale) return; if (stale) return;
if (res.ok) { if (res.ok) {
@@ -197,7 +277,7 @@ function AiChatPage() {
]); ]);
if (stale) return; if (stale) return;
setLoadingConv(false); setLoadingConv(false);
startPolling(convId); connectStomp(convId);
router.replace(`/ai-chat?id=${convId}`, { scroll: false }); router.replace(`/ai-chat?id=${convId}`, { scroll: false });
} }
@@ -205,9 +285,9 @@ function AiChatPage() {
return () => { return () => {
stale = true; stale = true;
if (pollRef.current) clearInterval(pollRef.current); if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; }
}; };
}, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, startPolling, fetchConversations, router]); }, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, connectStomp, fetchConversations, router]);
async function handleSend(e) { async function handleSend(e) {
e?.preventDefault(); e?.preventDefault();
@@ -250,6 +330,11 @@ function AiChatPage() {
const msg = await res.json(); const msg = await res.json();
setMessages((prev) => prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]); setMessages((prev) => prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]);
lastMessageIdRef.current = msg.id; lastMessageIdRef.current = msg.id;
if (!isEscalated) {
setBotTyping(true);
if (botTypingTimeoutRef.current) clearTimeout(botTypingTimeoutRef.current);
botTypingTimeoutRef.current = setTimeout(() => setBotTyping(false), 30000);
}
} catch { } catch {
setError("Network error. Please try again."); setError("Network error. Please try again.");
setInput(text); setInput(text);
@@ -320,7 +405,7 @@ function AiChatPage() {
} }
async function handleNewConversation() { async function handleNewConversation() {
if (pollRef.current) clearInterval(pollRef.current); if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; }
setError(null); setError(null);
setLoadingConv(true); setLoadingConv(true);
try { try {
@@ -330,7 +415,7 @@ function AiChatPage() {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
body: JSON.stringify({ message: "Hello! I'd like to chat with the AI assistant." }), body: JSON.stringify({}),
}); });
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => null); const data = await res.json().catch(() => null);
@@ -342,7 +427,7 @@ function AiChatPage() {
setConversation(conv); setConversation(conv);
await Promise.all([fetchMessages(conv.id), fetchConversations()]); await Promise.all([fetchMessages(conv.id), fetchConversations()]);
setLoadingConv(false); setLoadingConv(false);
startPolling(conv.id); connectStomp(conv.id);
router.replace(`/ai-chat?id=${conv.id}`, { scroll: false }); router.replace(`/ai-chat?id=${conv.id}`, { scroll: false });
} catch { } catch {
setError("Network error. Please try again."); setError("Network error. Please try again.");
@@ -351,9 +436,10 @@ function AiChatPage() {
} }
async function switchConversation(convId) { async function switchConversation(convId) {
if (pollRef.current) clearInterval(pollRef.current); if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; }
setMessages([]); setMessages([]);
setError(null); setError(null);
setBotTyping(false);
router.replace(`/ai-chat?id=${convId}`, { scroll: false }); router.replace(`/ai-chat?id=${convId}`, { scroll: false });
} }
@@ -364,12 +450,28 @@ function AiChatPage() {
method: "POST", method: "POST",
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}); });
router.push(`/chat?id=${conversation.id}`); setConversation((prev) => prev ? { ...prev, mode: "HUMAN" } : prev);
} catch { } catch {
setError("Could not connect to live support. Please try again."); setError("Could not connect to live support. Please try again.");
} }
} }
async function handleCloseConversation() {
if (!conversation || conversation.status === "CLOSED") return;
try {
const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${conversation.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ status: "CLOSED" }),
});
if (!res.ok) return;
const updated = await res.json();
setConversation(updated);
await fetchConversations();
} catch {
}
}
if (authLoading || loadingConv) { if (authLoading || loadingConv) {
return ( return (
<main style={s.page}> <main style={s.page}>
@@ -382,6 +484,8 @@ function AiChatPage() {
const isEscalated = conversation?.mode === "HUMAN"; const isEscalated = conversation?.mode === "HUMAN";
const isClosed = conversation?.status === "CLOSED"; const isClosed = conversation?.status === "CLOSED";
const hasStaff = !!conversation?.staffId;
const hasStaffMessage = messages.some((m) => m.senderId !== user?.id);
return ( return (
<main style={s.page}> <main style={s.page}>
@@ -402,7 +506,7 @@ function AiChatPage() {
<p style={s.sidebarEmpty}>No conversations yet.</p> <p style={s.sidebarEmpty}>No conversations yet.</p>
)} )}
<div style={{ overflowY: "auto", flex: 1 }}> <div style={{ overflowY: "auto", flex: 1 }}>
{conversations.map((conv) => ( {conversations.filter(c => c.status !== "CLOSED").map((conv) => (
<button <button
key={conv.id} key={conv.id}
style={{ ...s.convItem, ...(conv.id === conversation?.id ? s.convItemActive : {}) }} style={{ ...s.convItem, ...(conv.id === conversation?.id ? s.convItemActive : {}) }}
@@ -410,9 +514,7 @@ function AiChatPage() {
> >
<div style={s.convItemTop}> <div style={s.convItemTop}>
<span style={s.convItemSubject}>{conv.subject || `Conversation #${conv.id}`}</span> <span style={s.convItemSubject}>{conv.subject || `Conversation #${conv.id}`}</span>
<span style={{ ...s.convStatusBadge, ...(conv.status === "OPEN" ? s.convStatusOpen : s.convStatusClosed) }}> <span style={{ ...s.convStatusBadge, ...s.convStatusOpen }}>{conv.status}</span>
{conv.status}
</span>
</div> </div>
<div style={s.convItemBottom}> <div style={s.convItemBottom}>
<span style={s.convItemMode}>{conv.mode === "HUMAN" ? "👤 Live" : "🤖 AI"}</span> <span style={s.convItemMode}>{conv.mode === "HUMAN" ? "👤 Live" : "🤖 AI"}</span>
@@ -421,6 +523,30 @@ function AiChatPage() {
</button> </button>
))} ))}
</div> </div>
{conversations.some(c => c.status === "CLOSED") && (
<>
<button style={s.closedSectionToggle} onClick={() => setClosedExpanded(p => !p)}>
<span>Closed ({conversations.filter(c => c.status === "CLOSED").length})</span>
<span>{closedExpanded ? "▲" : "▼"}</span>
</button>
{closedExpanded && conversations.filter(c => c.status === "CLOSED").map((conv) => (
<button
key={conv.id}
style={{ ...s.convItem, ...s.convItemClosed, ...(conv.id === conversation?.id ? s.convItemActive : {}) }}
onClick={() => switchConversation(conv.id)}
>
<div style={s.convItemTop}>
<span style={s.convItemSubject}>{conv.subject || `Conversation #${conv.id}`}</span>
<span style={{ ...s.convStatusBadge, ...s.convStatusClosed }}>CLOSED</span>
</div>
<div style={s.convItemBottom}>
<span style={s.convItemMode}>{conv.mode === "HUMAN" ? "👤 Live" : "🤖 AI"}</span>
<span style={s.convItemDate}>{conv.createdAt ? new Date(conv.createdAt).toLocaleDateString() : ""}</span>
</div>
</button>
))}
</>
)}
<button style={s.newConvSidebarBtn} onClick={handleNewConversation}> <button style={s.newConvSidebarBtn} onClick={handleNewConversation}>
+ New Conversation + New Conversation
</button> </button>
@@ -444,41 +570,44 @@ function AiChatPage() {
<div style={s.chatCard}> <div style={s.chatCard}>
<div style={s.chatHeader}> <div style={s.chatHeader}>
<div style={s.chatHeaderLeft}> <div style={s.chatHeaderLeft}>
<div style={s.aiAvatar}>🐾</div> <div style={isEscalated ? s.agentAvatar : s.aiAvatar}>{isEscalated ? "👤" : "🐾"}</div>
<div> <div>
<div style={s.chatHeaderTitle}>Leon's Pet Assistant</div> <div style={s.chatHeaderTitle}>
<div style={s.chatHeaderStatus}> {isEscalated ? (hasStaff ? "Support Agent" : "Leon's Pet Store Support") : "Leon's Pet Assistant"}
<span style={s.statusDot} /> Online </div>
<div style={{ ...s.chatHeaderStatus, color: isClosed ? "#999" : isEscalated && hasStaff ? "#4CAF50" : isEscalated ? "#ff8c00" : undefined }}>
<span style={{ ...s.statusDot, background: isClosed ? "#999" : isEscalated && hasStaff ? "#4CAF50" : isEscalated ? "#ff8c00" : undefined }} />
{isClosed ? "Conversation closed" : isEscalated && hasStaff ? "Support agent connected" : isEscalated ? "Waiting for a support agent..." : "Online"}
</div> </div>
</div> </div>
</div> </div>
<div style={{ display: "flex", gap: "0.5rem" }}> <div style={{ display: "flex", gap: "0.5rem" }}>
{!isEscalated && !isClosed && ( {!isEscalated && !isClosed && (
<button <button style={s.humanBtn} onClick={handleSwitchToHuman} title="Connect with a human support agent">
style={s.humanBtn}
onClick={handleSwitchToHuman}
title="Connect with a human support agent"
>
Chat with a Real Person Chat with a Real Person
</button> </button>
)} )}
<button {!isClosed && (
style={s.liveBtn} <button style={s.closeConvBtn} onClick={handleCloseConversation} title="Close this conversation">
onClick={() => router.push("/chat")} Close Chat
title="Go to Live Support" </button>
> )}
Live Support
</button>
</div> </div>
</div> </div>
{isEscalated && !hasStaff && !hasStaffMessage && !isClosed && (
<div style={s.waitingBanner}>
<span style={s.waitingSpinner} />
A support agent will be with you shortly. You can send messages while you wait.
</div>
)}
<div style={s.messagesArea} ref={messagesAreaRef}> <div style={s.messagesArea} ref={messagesAreaRef}>
{messages.length === 0 && ( {messages.length === 0 && (
<div style={s.emptyState}> <div style={s.emptyState}>
<div style={s.emptyIcon}>🐾</div> <div style={s.emptyIcon}>{isEscalated ? "💬" : "🐾"}</div>
<p style={s.emptyText}> <p style={s.emptyText}>
Hello{user.fullName ? `, ${user.fullName.split(" ")[0]}` : ""}! I'm your pet care assistant. {isEscalated ? "Your conversation has started. A support agent will join soon." : `Hello${user.fullName ? `, ${user.fullName.split(" ")[0]}` : ""}! I'm your pet care assistant. Ask me about pet recommendations, care tips, supplies, or anything pet-related!`}
Ask me about pet recommendations, care tips, supplies, or anything pet-related!
</p> </p>
</div> </div>
)} )}
@@ -493,7 +622,7 @@ function AiChatPage() {
...(isOwn ? s.messageRowUser : s.messageRowAgent), ...(isOwn ? s.messageRowUser : s.messageRowAgent),
}} }}
> >
{!isOwn && <div style={s.aiAvatarSmall}>🐾</div>} {!isOwn && <div style={isEscalated ? s.agentAvatarSmall : s.aiAvatarSmall}>{isEscalated ? "👤" : "🐾"}</div>}
<div <div
style={{ style={{
...s.messageBubble, ...s.messageBubble,
@@ -507,16 +636,7 @@ function AiChatPage() {
</span> </span>
))} ))}
{msg.attachmentUrl && ( {msg.attachmentUrl && (
<div style={s.attachment}> <AttachmentPreview url={msg.attachmentUrl} name={msg.attachmentName} token={token} />
<a
href={msg.attachmentUrl}
target="_blank"
rel="noopener noreferrer"
style={s.attachmentLink}
>
📎 {msg.attachmentName || "Attachment"}
</a>
</div>
)} )}
<div style={{ ...s.timestamp, ...(isOwn ? s.timestampUser : {}) }}> <div style={{ ...s.timestamp, ...(isOwn ? s.timestampUser : {}) }}>
{msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : ""} {msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : ""}
@@ -530,6 +650,24 @@ function AiChatPage() {
</div> </div>
); );
})} })}
{botTyping && !isEscalated && (
<div style={{ ...s.messageRow, ...s.messageRowAgent }}>
<div style={s.aiAvatarSmall}>🐾</div>
<div style={{ ...s.messageBubble, ...s.bubbleAgent, display: "flex", alignItems: "center", gap: "4px", padding: "0.6rem 0.9rem" }}>
<span className="fc-dot" />
<span className="fc-dot" style={{ animationDelay: "0.2s" }} />
<span className="fc-dot" style={{ animationDelay: "0.4s" }} />
</div>
</div>
)}
{switchingConv && (
<div style={{ textAlign: "center", padding: "1rem", color: "#aaa", fontSize: "0.85rem" }}>
Loading messages
</div>
)}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
@@ -726,6 +864,21 @@ const s = {
}, },
convStatusOpen: { background: "#e6f9ee", color: "#1a7a3c" }, convStatusOpen: { background: "#e6f9ee", color: "#1a7a3c" },
convStatusClosed: { background: "#f0f0f0", color: "#888" }, convStatusClosed: { background: "#f0f0f0", color: "#888" },
convItemClosed: { opacity: 0.7 },
closedSectionToggle: {
width: "100%",
background: "#f5f5f5",
border: "none",
borderTop: "1px solid #e8e8e8",
padding: "0.5rem 1rem",
fontSize: "0.78rem",
fontWeight: 600,
color: "#666",
cursor: "pointer",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
},
newConvSidebarBtn: { newConvSidebarBtn: {
margin: "0.65rem 1rem", margin: "0.65rem 1rem",
background: "#333", background: "#333",
@@ -815,6 +968,60 @@ const s = {
cursor: "pointer", cursor: "pointer",
whiteSpace: "nowrap", whiteSpace: "nowrap",
}, },
agentAvatar: {
width: 44,
height: 44,
borderRadius: "50%",
background: "linear-gradient(135deg, #444, #666)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "1.3rem",
flexShrink: 0,
},
agentAvatarSmall: {
width: 30,
height: 30,
borderRadius: "50%",
background: "#e0e0e0",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "0.9rem",
flexShrink: 0,
},
closeConvBtn: {
background: "white",
border: "2px solid #c0392b",
color: "#c0392b",
borderRadius: 8,
padding: "0.45rem 0.9rem",
fontSize: "0.82rem",
fontWeight: 600,
cursor: "pointer",
whiteSpace: "nowrap",
},
waitingBanner: {
background: "#fff8f0",
borderBottom: "1px solid #ffe0b2",
padding: "0.6rem 1.25rem",
fontSize: "0.85rem",
color: "#7c4a00",
display: "flex",
alignItems: "center",
gap: "0.5rem",
flexShrink: 0,
},
waitingSpinner: {
display: "inline-block",
width: 12,
height: 12,
borderRadius: "50%",
border: "2px solid #ff8c00",
borderTopColor: "transparent",
animation: "spin 0.8s linear infinite",
flexShrink: 0,
},
noConvCard: { noConvCard: {
background: "white", background: "white",
borderRadius: 16, borderRadius: 16,

View File

@@ -20,20 +20,22 @@ const SPECIES_BREEDS = {
Other: ["Other"], Other: ["Other"],
}; };
// Explicit allowlists for species with restricted service availability. const SPECIES_EXCLUSIVE_SERVICES = {
// Species not listed here may use all services.
const SPECIES_SERVICE_ALLOWLIST = {
Bird: ["wing clipping", "beak and nail"], Bird: ["wing clipping", "beak and nail"],
Fish: ["aquarium health"], Fish: ["aquarium health"],
}; };
function getAvailableServices(services, species) { function getAvailableServices(services, species) {
if (!species) return services; if (!species) return services;
const allowlist = SPECIES_SERVICE_ALLOWLIST[species]; return services.filter((s) => {
if (!allowlist) return services; const name = s.serviceName.toLowerCase();
return services.filter((s) => for (const [exclusiveSpecies, keywords] of Object.entries(SPECIES_EXCLUSIVE_SERVICES)) {
allowlist.some((kw) => s.serviceName.toLowerCase().includes(kw)) if (exclusiveSpecies !== species && keywords.some((kw) => name.includes(kw))) {
); return false;
}
}
return true;
});
} }
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
@@ -381,6 +383,10 @@ function AppointmentsPage() {
const [showAddPetModal, setShowAddPetModal] = useState(false); const [showAddPetModal, setShowAddPetModal] = useState(false);
const [cancellingId, setCancellingId] = useState(null); const [cancellingId, setCancellingId] = useState(null);
const [apptSearch, setApptSearch] = useState("");
const [adoptionSearch, setAdoptionSearch] = useState("");
const [showPastAppts, setShowPastAppts] = useState(false);
const [showPastAdoptions, setShowPastAdoptions] = useState(false);
const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN"; const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
@@ -964,79 +970,164 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
<h2 className="appt-form-title">{canBookAppointments ? "Your Appointments" : "Appointments"}</h2> <h2 className="appt-form-title">{canBookAppointments ? "Your Appointments" : "Appointments"}</h2>
{loadingAppointments ? ( {loadingAppointments ? (
<p className="appt-loading">Loading appointments...</p> <p className="appt-loading">Loading appointments...</p>
) : appointments.length === 0 ? ( ) : (() => {
<p className="appt-empty">No appointments yet.</p> const activeAppts = appointments.filter((a) => a.appointmentStatus?.toLowerCase() === "booked");
) : ( const pastAppts = appointments.filter((a) => a.appointmentStatus?.toLowerCase() !== "booked");
<div className="appt-list"> const q = apptSearch.toLowerCase();
{appointments.map((a) => ( const filteredActive = activeAppts.filter((a) =>
<div key={a.appointmentId} className="appt-card"> !q || [a.serviceName, a.storeName, a.petName].some((v) => v?.toLowerCase().includes(q))
<div className="appt-card-header"> );
<span className="appt-card-service">{a.serviceName}</span> return (
<span className={`appt-card-status appt-card-status--${a.appointmentStatus?.toLowerCase()}`}> <>
{a.appointmentStatus} <input
</span> className="appt-search"
type="text"
placeholder="Search appointments…"
value={apptSearch}
onChange={(e) => setApptSearch(e.target.value)}
/>
{filteredActive.length === 0 ? (
<p className="appt-empty">{activeAppts.length === 0 ? "No active appointments." : "No results."}</p>
) : (
<div className="appt-list">
{filteredActive.map((a) => (
<div key={a.appointmentId} className="appt-card">
<div className="appt-card-header">
<span className="appt-card-service">{a.serviceName}</span>
<span className={`appt-card-status appt-card-status--${a.appointmentStatus?.toLowerCase()}`}>
{a.appointmentStatus}
</span>
</div>
<div className="appt-card-details">
<span>{a.storeName}</span>
<span>{a.appointmentDate} at {formatTime(a.appointmentTime)}</span>
</div>
{a.petName && (
<div className="appt-card-pets">Pet: {a.petName}</div>
)}
<div className="appt-card-actions">
<button
type="button"
className="appt-cancel-btn"
disabled={cancellingId === a.appointmentId}
onClick={() => handleCancelAppointment(a.appointmentId)}
>
{cancellingId === a.appointmentId ? "Cancelling..." : "Cancel"}
</button>
</div>
</div>
))}
</div> </div>
<div className="appt-card-details"> )}
<span>{a.storeName}</span> {pastAppts.length > 0 && (
<span>{a.appointmentDate} at {formatTime(a.appointmentTime)}</span> <div className="appt-past-section">
<button className="appt-past-toggle" onClick={() => setShowPastAppts((v) => !v)}>
{showPastAppts ? "Hide" : "Show"} past appointments ({pastAppts.length})
</button>
{showPastAppts && (
<div className="appt-list appt-list--past">
{pastAppts.map((a) => (
<div key={a.appointmentId} className="appt-card appt-card--past">
<div className="appt-card-header">
<span className="appt-card-service">{a.serviceName}</span>
<span className={`appt-card-status appt-card-status--${a.appointmentStatus?.toLowerCase()}`}>
{a.appointmentStatus}
</span>
</div>
<div className="appt-card-details">
<span>{a.storeName}</span>
<span>{a.appointmentDate} at {formatTime(a.appointmentTime)}</span>
</div>
{a.petName && (
<div className="appt-card-pets">Pet: {a.petName}</div>
)}
</div>
))}
</div>
)}
</div> </div>
{a.petName && ( )}
<div className="appt-card-pets"> </>
Pet: {a.petName} );
</div> })()}
)}
{a.appointmentStatus?.toLowerCase() === "booked" && (
<div className="appt-card-actions">
<button
type="button"
className="appt-cancel-btn"
disabled={cancellingId === a.appointmentId}
onClick={() => handleCancelAppointment(a.appointmentId)}
>
{cancellingId === a.appointmentId ? "Cancelling..." : "Cancel"}
</button>
</div>
)}
</div>
))}
</div>
)}
<h2 className="appt-form-title" style={{ marginTop: "2rem" }}>{canBookAppointments ? "Your Adoptions" : "Adoptions"}</h2> <h2 className="appt-form-title" style={{ marginTop: "2rem" }}>{canBookAppointments ? "Your Adoptions" : "Adoptions"}</h2>
{loadingAdoptions ? ( {loadingAdoptions ? (
<p className="appt-loading">Loading adoptions...</p> <p className="appt-loading">Loading adoptions...</p>
) : adoptions.length === 0 ? ( ) : (() => {
<p className="appt-empty">No adoption requests yet.</p> const activeAdoptions = adoptions.filter((a) => a.adoptionStatus?.toLowerCase() === "pending");
) : ( const pastAdoptions = adoptions.filter((a) => a.adoptionStatus?.toLowerCase() !== "pending");
<div className="appt-list"> const q = adoptionSearch.toLowerCase();
{adoptions.map((a) => ( const filteredActive = activeAdoptions.filter((a) =>
<div key={a.adoptionId} className="appt-card"> !q || [a.petName, a.sourceStoreName].some((v) => v?.toLowerCase().includes(q))
<div className="appt-card-header"> );
<span className="appt-card-service">{a.petName}</span> return (
<span className={`appt-card-status appt-card-status--${a.adoptionStatus?.toLowerCase()}`}> <>
{a.adoptionStatus} <input
</span> className="appt-search"
type="text"
placeholder="Search adoptions…"
value={adoptionSearch}
onChange={(e) => setAdoptionSearch(e.target.value)}
/>
{filteredActive.length === 0 ? (
<p className="appt-empty">{activeAdoptions.length === 0 ? "No active adoption requests." : "No results."}</p>
) : (
<div className="appt-list">
{filteredActive.map((a) => (
<div key={a.adoptionId} className="appt-card">
<div className="appt-card-header">
<span className="appt-card-service">{a.petName}</span>
<span className={`appt-card-status appt-card-status--${a.adoptionStatus?.toLowerCase()}`}>
{a.adoptionStatus}
</span>
</div>
<div className="appt-card-details">
<span>{a.sourceStoreName}</span>
<span>{a.adoptionDate}</span>
</div>
<div className="appt-card-actions">
<button
type="button"
className="appt-cancel-btn"
disabled={cancellingId === a.adoptionId}
onClick={() => handleCancelAdoption(a.adoptionId)}
>
{cancellingId === a.adoptionId ? "Cancelling..." : "Cancel"}
</button>
</div>
</div>
))}
</div> </div>
<div className="appt-card-details"> )}
<span>{a.sourceStoreName}</span> {pastAdoptions.length > 0 && (
<span>{a.adoptionDate}</span> <div className="appt-past-section">
<button className="appt-past-toggle" onClick={() => setShowPastAdoptions((v) => !v)}>
{showPastAdoptions ? "Hide" : "Show"} past adoptions ({pastAdoptions.length})
</button>
{showPastAdoptions && (
<div className="appt-list appt-list--past">
{pastAdoptions.map((a) => (
<div key={a.adoptionId} className="appt-card appt-card--past">
<div className="appt-card-header">
<span className="appt-card-service">{a.petName}</span>
<span className={`appt-card-status appt-card-status--${a.adoptionStatus?.toLowerCase()}`}>
{a.adoptionStatus}
</span>
</div>
<div className="appt-card-details">
<span>{a.sourceStoreName}</span>
<span>{a.adoptionDate}</span>
</div>
</div>
))}
</div>
)}
</div> </div>
{a.adoptionStatus?.toLowerCase() === "pending" && ( )}
<div className="appt-card-actions"> </>
<button );
type="button" })()}
className="appt-cancel-btn"
disabled={cancellingId === a.adoptionId}
onClick={() => handleCancelAdoption(a.adoptionId)}
>
{cancellingId === a.adoptionId ? "Cancelling..." : "Cancel"}
</button>
</div>
)}
</div>
))}
</div>
)}
</div> </div>
</section> </section>
</main> </main>

View File

@@ -57,6 +57,11 @@ function PaymentForm({ clientSecret, totalAmount, onSuccess, onCancel }) {
<p className="cart-payment-total"> <p className="cart-payment-total">
Total to pay: <strong>${parseFloat(totalAmount).toFixed(2)}</strong> Total to pay: <strong>${parseFloat(totalAmount).toFixed(2)}</strong>
</p> </p>
<div className="cart-demo-banner">
<strong>Demo mode</strong> no real charge. Use test card:
<span className="cart-demo-card">4242 4242 4242 4242</span>
· any future date · any 3-digit CVC
</div>
<PaymentElement /> <PaymentElement />
{payError && <p className="cart-error-msg">{payError}</p>} {payError && <p className="cart-error-msg">{payError}</p>}
<div className="cart-payment-actions"> <div className="cart-payment-actions">

View File

@@ -4,9 +4,69 @@ import dynamic from "next/dynamic";
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
import { createStompClient } from "@/lib/chatSocket";
const API_BASE = ""; const API_BASE = "";
const POLL_INTERVAL = 2500;
function isImageFilename(name) {
return /\.(jpe?g|png|gif|webp|bmp|svg)$/i.test(name || "");
}
function AttachmentPreview({ url, name, token }) {
const [blobUrl, setBlobUrl] = useState(null);
const isImage = isImageFilename(name);
useEffect(() => {
if (!url || !token) return;
let objectUrl;
fetch(url, { headers: { Authorization: `Bearer ${token}` } })
.then((r) => (r.ok ? r.blob() : null))
.then((blob) => {
if (blob) {
objectUrl = URL.createObjectURL(blob);
setBlobUrl(objectUrl);
}
})
.catch(() => {});
return () => { if (objectUrl) URL.revokeObjectURL(objectUrl); };
}, [url, token]);
if (isImage) {
return (
<div style={{ marginTop: "0.5rem" }}>
{blobUrl ? (
<img
src={blobUrl}
alt={name || "attachment"}
style={{ maxWidth: "220px", maxHeight: "220px", borderRadius: "8px", cursor: "pointer", display: "block" }}
onClick={() => window.open(blobUrl, "_blank")}
/>
) : (
<span style={{ fontSize: "0.8rem", opacity: 0.7 }}>📎 Loading image</span>
)}
</div>
);
}
return (
<div style={{ marginTop: "0.4rem" }}>
<a
href="#"
style={{ color: "inherit", fontSize: "0.85rem", opacity: 0.85 }}
onClick={(e) => {
e.preventDefault();
if (!blobUrl) return;
const a = document.createElement("a");
a.href = blobUrl;
a.download = name || "attachment";
a.click();
}}
>
📎 {name || "Attachment"}
</a>
</div>
);
}
function ChatPage() { function ChatPage() {
const { user, token, loading: authLoading } = useAuth(); const { user, token, loading: authLoading } = useAuth();
@@ -22,15 +82,18 @@ function ChatPage() {
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [conversations, setConversations] = useState([]); const [conversations, setConversations] = useState([]);
const [convsLoading, setConvsLoading] = useState(false); const [convsLoading, setConvsLoading] = useState(false);
const [closedExpanded, setClosedExpanded] = useState(false);
const [selectedFile, setSelectedFile] = useState(null); const [selectedFile, setSelectedFile] = useState(null);
const [switchingConv, setSwitchingConv] = useState(false);
const messagesEndRef = useRef(null); const messagesEndRef = useRef(null);
const messagesAreaRef = useRef(null); const messagesAreaRef = useRef(null);
const inputRef = useRef(null); const inputRef = useRef(null);
const pollRef = useRef(null); const stompRef = useRef(null);
const lastMessageIdRef = useRef(null); const lastMessageIdRef = useRef(null);
const fileInputRef = useRef(null); const fileInputRef = useRef(null);
const lastScrolledIdRef = useRef(null); const lastScrolledIdRef = useRef(null);
const initialLoadDoneRef = useRef(false);
useEffect(() => { useEffect(() => {
if (!authLoading && !user) { if (!authLoading && !user) {
@@ -42,17 +105,17 @@ function ChatPage() {
if (messages.length === 0) return; if (messages.length === 0) return;
const lastMsg = messages[messages.length - 1]; const lastMsg = messages[messages.length - 1];
if (lastMsg.id === lastScrolledIdRef.current) return; if (lastMsg.id === lastScrolledIdRef.current) return;
if (!initialLoadDoneRef.current) return;
lastScrolledIdRef.current = lastMsg.id; lastScrolledIdRef.current = lastMsg.id;
const area = messagesAreaRef.current; const area = messagesAreaRef.current;
if (!area) return; if (!area) return;
const nearBottom = area.scrollHeight - area.scrollTop - area.clientHeight < 80; const nearBottom = area.scrollHeight - area.scrollTop - area.clientHeight < 150;
if (nearBottom) { if (nearBottom) area.scrollTop = area.scrollHeight;
area.scrollTop = area.scrollHeight;
}
}, [messages]); }, [messages]);
const fetchMessages = useCallback(async (convId) => { const fetchMessages = useCallback(async (convId) => {
if (!token || !convId) return; if (!token || !convId) return;
initialLoadDoneRef.current = false;
try { try {
const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, { const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
@@ -67,7 +130,13 @@ function ChatPage() {
if (data.length > 0) { if (data.length > 0) {
lastMessageIdRef.current = data[data.length - 1].id; lastMessageIdRef.current = data[data.length - 1].id;
lastScrolledIdRef.current = data[data.length - 1].id;
} }
setTimeout(() => {
const area = messagesAreaRef.current;
if (area) area.scrollTop = area.scrollHeight;
initialLoadDoneRef.current = true;
}, 50);
} }
} }
@@ -114,40 +183,34 @@ function ChatPage() {
} }
}, [token]); }, [token]);
const startPolling = useCallback((convId) => { const connectStomp = useCallback((convId) => {
if (pollRef.current) clearInterval(pollRef.current); if (stompRef.current) {
pollRef.current = setInterval(async () => { stompRef.current.deactivate();
if (!token || !convId) return; stompRef.current = null;
try { }
const [msgsRes, convRes] = await Promise.all([ const client = createStompClient(token);
fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, { client.onConnect = () => {
headers: { Authorization: `Bearer ${token}` }, client.subscribe(`/topic/chat/conversations/${convId}`, (frame) => {
}), try {
fetch(`${API_BASE}/api/v1/chat/conversations/${convId}`, { const msg = JSON.parse(frame.body);
headers: { Authorization: `Bearer ${token}` }, setMessages((prev) => prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]);
}), lastMessageIdRef.current = msg.id;
]); } catch { /* silent */ }
if (msgsRes.ok) { });
const data = await msgsRes.json(); const convTopic = user?.role === "CUSTOMER"
if (Array.isArray(data)) { ? `/user/queue/chat/conversations`
const lastId = data.length > 0 ? data[data.length - 1].id : null; : `/topic/chat/conversations`;
if (lastId !== lastMessageIdRef.current) { client.subscribe(convTopic, (frame) => {
lastMessageIdRef.current = lastId; try {
setMessages(data); const conv = JSON.parse(frame.body);
} if (conv.id === convId) setConversation(conv);
} setConversations((prev) => prev.map((c) => c.id === conv.id ? conv : c));
} } catch { /* silent */ }
if (convRes.ok) { });
const convData = await convRes.json(); };
setConversation(convData); stompRef.current = client;
} client.activate();
} }, [token, user?.role]);
catch {
//Silent
}
}, POLL_INTERVAL);
}, [token]);
useEffect(() => { useEffect(() => {
if (!token || authLoading) return; if (!token || authLoading) return;
@@ -194,16 +257,16 @@ function ChatPage() {
]); ]);
if (stale) return; if (stale) return;
setLoadingConv(false); setLoadingConv(false);
startPolling(convId); connectStomp(convId);
} }
init(); init();
return () => { return () => {
stale = true; stale = true;
if (pollRef.current) clearInterval(pollRef.current); if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; }
}; };
}, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, startPolling, fetchConversations]); }, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, connectStomp, fetchConversations]);
async function handleSend(e) { async function handleSend(e) {
e?.preventDefault(); e?.preventDefault();
@@ -347,7 +410,7 @@ function ChatPage() {
setConversation(conv); setConversation(conv);
await Promise.all([fetchMessages(conv.id), fetchConversations()]); await Promise.all([fetchMessages(conv.id), fetchConversations()]);
setLoadingConv(false); setLoadingConv(false);
startPolling(conv.id); connectStomp(conv.id);
router.replace(`/chat?id=${conv.id}`, { scroll: false }); router.replace(`/chat?id=${conv.id}`, { scroll: false });
} catch { } catch {
setError("Network error. Please try again."); setError("Network error. Please try again.");
@@ -356,7 +419,7 @@ function ChatPage() {
} }
async function switchConversation(convId) { async function switchConversation(convId) {
if (pollRef.current) clearInterval(pollRef.current); if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; }
setMessages([]); setMessages([]);
setError(null); setError(null);
router.replace(`/chat?id=${convId}`, { scroll: false }); router.replace(`/chat?id=${convId}`, { scroll: false });
@@ -426,7 +489,7 @@ function ChatPage() {
<p style={s.sidebarEmpty}>No conversations yet.</p> <p style={s.sidebarEmpty}>No conversations yet.</p>
)} )}
<div style={{ overflowY: "auto", flex: 1 }}> <div style={{ overflowY: "auto", flex: 1 }}>
{conversations.map((conv) => ( {conversations.filter(c => c.status !== "CLOSED").map((conv) => (
<button <button
key={conv.id} key={conv.id}
style={{ ...s.convItem, ...(conv.id === conversation?.id ? s.convItemActive : {}) }} style={{ ...s.convItem, ...(conv.id === conversation?.id ? s.convItemActive : {}) }}
@@ -434,9 +497,7 @@ function ChatPage() {
> >
<div style={s.convItemTop}> <div style={s.convItemTop}>
<span style={s.convItemSubject}>{conv.subject || `Conversation #${conv.id}`}</span> <span style={s.convItemSubject}>{conv.subject || `Conversation #${conv.id}`}</span>
<span style={{ ...s.convStatusBadge, ...(conv.status === "OPEN" ? s.convStatusOpen : s.convStatusClosed) }}> <span style={{ ...s.convStatusBadge, ...s.convStatusOpen }}>{conv.status}</span>
{conv.status}
</span>
</div> </div>
<div style={s.convItemBottom}> <div style={s.convItemBottom}>
<span style={s.convItemMode}>{conv.mode === "HUMAN" ? "👤 Live" : "🤖 AI"}</span> <span style={s.convItemMode}>{conv.mode === "HUMAN" ? "👤 Live" : "🤖 AI"}</span>
@@ -445,6 +506,30 @@ function ChatPage() {
</button> </button>
))} ))}
</div> </div>
{conversations.some(c => c.status === "CLOSED") && (
<>
<button style={s.closedSectionToggle} onClick={() => setClosedExpanded(p => !p)}>
<span>Closed ({conversations.filter(c => c.status === "CLOSED").length})</span>
<span>{closedExpanded ? "▲" : "▼"}</span>
</button>
{closedExpanded && conversations.filter(c => c.status === "CLOSED").map((conv) => (
<button
key={conv.id}
style={{ ...s.convItem, ...s.convItemClosed, ...(conv.id === conversation?.id ? s.convItemActive : {}) }}
onClick={() => switchConversation(conv.id)}
>
<div style={s.convItemTop}>
<span style={s.convItemSubject}>{conv.subject || `Conversation #${conv.id}`}</span>
<span style={{ ...s.convStatusBadge, ...s.convStatusClosed }}>CLOSED</span>
</div>
<div style={s.convItemBottom}>
<span style={s.convItemMode}>{conv.mode === "HUMAN" ? "👤 Live" : "🤖 AI"}</span>
<span style={s.convItemDate}>{conv.createdAt ? new Date(conv.createdAt).toLocaleDateString() : ""}</span>
</div>
</button>
))}
</>
)}
<button style={s.newConvSidebarBtn} onClick={() => handleNewConversation()}> <button style={s.newConvSidebarBtn} onClick={() => handleNewConversation()}>
+ New Conversation + New Conversation
</button> </button>
@@ -488,13 +573,15 @@ function ChatPage() {
Close Chat Close Chat
</button> </button>
)} )}
<button {!isHuman && (
style={s.aiBtn} <button
onClick={() => router.push("/ai-chat")} style={s.aiBtn}
title="Back to AI Assistant" onClick={() => router.push("/ai-chat")}
> title="Back to AI Assistant"
AI Assistant >
</button> AI Assistant
</button>
)}
</div> </div>
</div> </div>
@@ -540,16 +627,7 @@ function ChatPage() {
</span> </span>
))} ))}
{msg.attachmentUrl && ( {msg.attachmentUrl && (
<div style={s.attachment}> <AttachmentPreview url={msg.attachmentUrl} name={msg.attachmentName} token={token} />
<a
href={msg.attachmentUrl}
target="_blank"
rel="noopener noreferrer"
style={s.attachmentLink}
>
📎 {msg.attachmentName || "Attachment"}
</a>
</div>
)} )}
<div style={{ ...s.timestamp, ...(isOwn ? s.timestampUser : {}) }}> <div style={{ ...s.timestamp, ...(isOwn ? s.timestampUser : {}) }}>
{msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : ""} {msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : ""}
@@ -563,6 +641,11 @@ function ChatPage() {
</div> </div>
); );
})} })}
{switchingConv && (
<div style={{ textAlign: "center", padding: "1rem", color: "#aaa", fontSize: "0.85rem" }}>
Loading messages
</div>
)}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
@@ -1102,6 +1185,21 @@ const s = {
}, },
convStatusOpen: { background: "#e6f9ee", color: "#1a7a3c" }, convStatusOpen: { background: "#e6f9ee", color: "#1a7a3c" },
convStatusClosed: { background: "#f0f0f0", color: "#888" }, convStatusClosed: { background: "#f0f0f0", color: "#888" },
convItemClosed: { opacity: 0.7 },
closedSectionToggle: {
width: "100%",
background: "#f5f5f5",
border: "none",
borderTop: "1px solid #e8e8e8",
padding: "0.5rem 1rem",
fontSize: "0.78rem",
fontWeight: 600,
color: "#666",
cursor: "pointer",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
},
newConvSidebarBtn: { newConvSidebarBtn: {
margin: "0.65rem 1rem", margin: "0.65rem 1rem",
background: "#333", background: "#333",

View File

@@ -38,6 +38,10 @@ export default function ContactPage() {
async function handleSend(e) { async function handleSend(e) {
e.preventDefault(); e.preventDefault();
if (!token) {
setSendError("Please log in to send a message.");
return;
}
setSending(true); setSending(true);
setSendError(null); setSendError(null);
try { try {
@@ -50,7 +54,7 @@ export default function ContactPage() {
setSendSuccess(true); setSendSuccess(true);
setSubject(""); setSubject("");
setBody(""); setBody("");
} catch (err) { } catch {
setSendError("Failed to send message. Please try again."); setSendError("Failed to send message. Please try again.");
} finally { } finally {
setSending(false); setSending(false);
@@ -61,21 +65,19 @@ export default function ContactPage() {
<main className="info-page"> <main className="info-page">
<section className="info-hero"> <section className="info-hero">
<h1 className="info-title">Contact Us</h1> <h1 className="info-title">Contact Us</h1>
<p className="info-subtitle">Reach the team, find a location, or connect with store personnel.</p> <p className="info-subtitle">Reach the team, find a location, or send us a message.</p>
<div className="title-decoration"></div> <div className="title-decoration"></div>
</section> </section>
<section className="info-content"> <section className="contact-layout">
<div className="info-card"> <div className="info-card">
<h2>General Contact</h2> <h2>Get in Touch</h2>
<p>Email: hello@leonspetstore.com.au</p> <p>Email: hello@leonspetstore.com.au</p>
<p>Phone: (03) 9000 0000</p> <p>Phone: (03) 9000 0000</p>
<p>Hours: MonSat, 9:00 AM 6:00 PM</p> <p>Hours: MonSat, 9:00 AM 6:00 PM</p>
</div>
{token && ( <div className="contact-form-section">
<div className="info-card"> <h3>Send Us a Message</h3>
<h2>Send Us a Message</h2>
{sendSuccess ? ( {sendSuccess ? (
<p className="contact-success">Your message has been sent. We&apos;ll be in touch soon.</p> <p className="contact-success">Your message has been sent. We&apos;ll be in touch soon.</p>
) : ( ) : (
@@ -100,7 +102,7 @@ export default function ContactPage() {
onChange={(e) => setBody(e.target.value)} onChange={(e) => setBody(e.target.value)}
required required
maxLength={2000} maxLength={2000}
rows={6} rows={5}
/> />
</label> </label>
{sendError && <p className="contact-error">{sendError}</p>} {sendError && <p className="contact-error">{sendError}</p>}
@@ -110,18 +112,14 @@ export default function ContactPage() {
</form> </form>
)} )}
</div> </div>
)} </div>
<div className="info-card"> <div className="info-card">
<h2>Store Locations</h2> <h2>Store Locations</h2>
{loading && <p>Loading locations...</p>} {loading && <p>Loading locations...</p>}
{error && <p style={{ color: "red" }}>Failed to load locations: {error}</p>} {error && <p style={{ color: "red" }}>Failed to load locations: {error}</p>}
{!loading && !error && locations.length === 0 && <p>No store locations found.</p>}
{!loading && !error && locations.length === 0 && (
<p>No store locations found.</p>
)}
{!loading && !error && locations.length > 0 && ( {!loading && !error && locations.length > 0 && (
<div className="info-card-grid"> <div className="info-card-grid">

View File

@@ -154,7 +154,7 @@ body {
.image-links-container { .image-links-container {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(3, 1fr);
gap: 2rem; gap: 2rem;
justify-content: center; justify-content: center;
align-items: stretch; align-items: stretch;
@@ -685,33 +685,39 @@ body {
} }
.info-page { .info-page {
min-height: 100vh;
background: linear-gradient(to bottom, #f9f9f9, #ffffff); background: linear-gradient(to bottom, #f9f9f9, #ffffff);
} }
.info-hero { .info-hero {
text-align: center; text-align: center;
padding: 4rem 2rem 3rem; padding: 2.5rem 2rem 1.5rem;
} }
.info-title { .info-title {
font-size: 3rem; font-size: 1.6rem;
color: #333; color: #333;
margin-bottom: 1rem; margin-bottom: 0.5rem;
font-weight: 700; font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
} }
.info-subtitle { .info-subtitle {
font-size: 1.25rem; font-size: 1rem;
color: #666; color: #888;
margin-bottom: 1.5rem; margin-bottom: 1rem;
max-width: 520px;
margin-left: auto;
margin-right: auto;
line-height: 1.6;
} }
.info-content { .info-content {
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 0 2rem 4rem; padding: 0 2rem 1.5rem;
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem; gap: 1.5rem;
} }
@@ -733,6 +739,7 @@ body {
padding-left: 1.2rem; padding-left: 1.2rem;
display: grid; display: grid;
gap: 0.5rem; gap: 0.5rem;
list-style-type: disc;
} }
.info-card-grid { .info-card-grid {
@@ -892,6 +899,10 @@ body {
gap: 1.5rem; gap: 1.5rem;
} }
.info-content {
grid-template-columns: 1fr;
}
.main-title { .main-title {
font-size: 2rem; font-size: 2rem;
} }
@@ -1028,8 +1039,7 @@ body {
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
justify-self: end; justify-self: end;
padding-left: 1.5rem; min-width: 0;
flex-shrink: 0;
} }
.nav-greeting { .nav-greeting {
@@ -1706,6 +1716,49 @@ body {
cursor: default; cursor: default;
} }
.appt-search {
width: 100%;
padding: 0.5rem 0.75rem;
margin-bottom: 0.75rem;
border: 1px solid #e0e0e0;
border-radius: 8px;
font-size: 0.9rem;
background: #fff;
box-sizing: border-box;
}
.appt-search:focus {
outline: none;
border-color: #e68672;
}
.appt-past-section {
margin-top: 1rem;
}
.appt-past-toggle {
background: none;
border: none;
padding: 0;
font-size: 0.85rem;
color: #888;
cursor: pointer;
text-decoration: underline;
margin-bottom: 0.75rem;
}
.appt-past-toggle:hover {
color: #555;
}
.appt-list--past {
opacity: 0.7;
}
.appt-card--past {
background: #f9f9f9;
}
/* Adoption Pet Selection */ /* Adoption Pet Selection */
.appt-adopt-grid { .appt-adopt-grid {
@@ -2024,6 +2077,68 @@ body {
color: #333; color: #333;
} }
.profile-orders-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.profile-order-card {
border: 1px solid #f0f0f0;
border-radius: 10px;
padding: 0.85rem 1rem;
background: #fafafa;
}
.profile-order-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
.profile-order-date {
font-size: 0.85rem;
color: #555;
font-weight: 600;
}
.profile-order-total {
font-size: 0.95rem;
font-weight: 700;
color: #222;
}
.profile-order-meta {
display: flex;
gap: 1rem;
font-size: 0.78rem;
color: #999;
margin-bottom: 0.5rem;
}
.profile-order-items {
margin: 0.25rem 0 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.2rem;
border-top: 1px solid #ececec;
padding-top: 0.5rem;
}
.profile-order-items li {
display: flex;
justify-content: space-between;
font-size: 0.82rem;
color: #444;
}
.profile-order-item-price {
color: #888;
}
/* Store Selector */ /* Store Selector */
.nav-store-select { .nav-store-select {
@@ -2037,6 +2152,7 @@ body {
margin-right: 0.5rem; margin-right: 0.5rem;
outline: none; outline: none;
transition: background 0.2s ease; transition: background 0.2s ease;
max-width: 160px;
} }
.nav-store-select option { .nav-store-select option {
@@ -2639,6 +2755,25 @@ body {
margin: 0; margin: 0;
} }
.cart-demo-banner {
background: #fffbeb;
border: 1px solid #fcd34d;
border-radius: 8px;
padding: 0.6rem 0.9rem;
font-size: 0.85rem;
color: #78350f;
line-height: 1.5;
}
.cart-demo-card {
font-family: monospace;
font-weight: 700;
margin: 0 0.25rem;
background: #fef3c7;
padding: 0.1rem 0.35rem;
border-radius: 4px;
}
.cart-payment-actions { .cart-payment-actions {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -2827,7 +2962,10 @@ body {
.nav-auth { .nav-auth {
gap: 0.35rem; gap: 0.35rem;
padding-left: 0.5rem; }
.nav-greeting {
display: none;
} }
} }
@@ -2893,7 +3031,8 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
margin-left: auto; grid-column: 3;
justify-self: end;
} }
.nav-links, .nav-links,
@@ -3022,13 +3161,20 @@ body {
width: 100%; width: 100%;
} }
.adopt-search-form { .adopt-controls-row {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
} }
.adopt-search-form {
flex-direction: column;
align-items: stretch;
width: 100%;
}
.adopt-search-input { .adopt-search-input {
max-width: 100%; max-width: 100%;
width: 100%;
} }
.adopt-search-btn, .adopt-search-btn,
@@ -3094,6 +3240,10 @@ img, video, iframe {
max-width: 100%; max-width: 100%;
} }
.contact-layout { display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; max-width: 1200px; margin: 0 auto; padding: 0 2rem 3rem; }
@media (max-width: 768px) { .contact-layout { grid-template-columns: 1fr; } }
.contact-form-section { margin-top: 1.5rem; border-top: 1px solid #f0f0f0; padding-top: 1.5rem; }
.contact-form-section h3 { margin: 0 0 1rem; font-size: 1rem; color: #333; }
.contact-form { display: flex; flex-direction: column; gap: 1rem; } .contact-form { display: flex; flex-direction: column; gap: 1rem; }
.contact-label { display: flex; flex-direction: column; gap: 0.4rem; font-weight: 500; color: #333; font-size: 0.95rem; } .contact-label { display: flex; flex-direction: column; gap: 0.4rem; font-weight: 500; color: #333; font-size: 0.95rem; }
.contact-input, .contact-textarea { border: 1px solid #ddd; border-radius: 8px; padding: 0.6rem 0.8rem; font-size: 0.95rem; font-family: inherit; resize: vertical; } .contact-input, .contact-textarea { border: 1px solid #ddd; border-radius: 8px; padding: 0.6rem 0.8rem; font-size: 0.95rem; font-family: inherit; resize: vertical; }
@@ -3103,7 +3253,43 @@ img, video, iframe {
.contact-submit-btn:disabled { opacity: 0.6; cursor: not-allowed; } .contact-submit-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.contact-error { color: #c0392b; font-size: 0.9rem; } .contact-error { color: #c0392b; font-size: 0.9rem; }
.contact-success { color: #166534; background: #dcfce7; border: 1px solid #bbf7d0; border-radius: 8px; padding: 0.75rem 1rem; } .contact-success { color: #166534; background: #dcfce7; border: 1px solid #bbf7d0; border-radius: 8px; padding: 0.75rem 1rem; }
.pagination-controls { display: flex; align-items: center; justify-content: center; gap: 1rem; padding: 1.5rem 1rem; } .pagination-controls { display: flex; align-items: center; justify-content: center; gap: 0.4rem; padding: 1.5rem 1rem; flex-wrap: wrap; }
.pagination-btn { background: #333; color: white; border: none; border-radius: 8px; padding: 0.5rem 1.2rem; font-size: 0.9rem; font-weight: 600; cursor: pointer; } .pagination-btn { background: #e8e8e8; color: #333; border: none; border-radius: 8px; padding: 0.5rem 0.9rem; font-size: 0.9rem; font-weight: 600; cursor: pointer; transition: background 0.15s; }
.pagination-btn:disabled { background: #ccc; cursor: not-allowed; } .pagination-btn:hover:not(:disabled) { background: #d0d0d0; }
.pagination-btn:disabled { background: #f0f0f0; color: #aaa; cursor: not-allowed; }
.pagination-btn--active { background: #e68672; color: white; }
.pagination-btn--active:hover { background: #d4705e; }
.pagination-ellipsis { padding: 0.5rem 0.25rem; color: #888; font-weight: 600; }
.pagination-info { font-size: 0.9rem; color: #555; font-weight: 500; } .pagination-info { font-size: 0.9rem; color: #555; font-weight: 500; }
@media (max-width: 768px) {
.info-title {
font-size: 1.3rem;
}
.info-subtitle {
font-size: 0.95rem;
}
}
@media (max-width: 480px) {
.info-title {
font-size: 1.2rem;
}
.info-subtitle {
font-size: 0.9rem;
}
.image-links-container {
grid-template-columns: 1fr;
gap: 1rem;
}
.adopt-grid {
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
}
@media (max-width: 360px) {
.adopt-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -79,8 +79,8 @@ export default function Home() {
{/* About Us */} {/* About Us */}
<section className="info-page"> <section className="info-page">
<div className="info-hero"> <div className="info-hero">
<h2 className="info-title">About Leon&apos;s Pet Store</h2> <h2 className="info-title">About Us</h2>
<p className="info-subtitle">Your trusted local destination for pet care, adoption, and supplies built on a love for animals and community.</p> <p className="info-subtitle">A full-service pet store built on a love for animals and community.</p>
<div className="title-decoration"></div> <div className="title-decoration"></div>
</div> </div>
<div className="info-content"> <div className="info-content">

View File

@@ -25,6 +25,8 @@ export default function ProfilePage() {
const [pets, setPets] = useState([]); const [pets, setPets] = useState([]);
const [loadingPets, setLoadingPets] = useState(false); const [loadingPets, setLoadingPets] = useState(false);
const [orders, setOrders] = useState([]);
const [loadingOrders, setLoadingOrders] = useState(false);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [editingPet, setEditingPet] = useState(null); const [editingPet, setEditingPet] = useState(null);
const [petName, setPetName] = useState(""); const [petName, setPetName] = useState("");
@@ -120,11 +122,27 @@ export default function ProfilePage() {
}; };
}, [clearPetImageObjectUrls]); }, [clearPetImageObjectUrls]);
const loadOrders = useCallback(async () => {
if (!token) return;
setLoadingOrders(true);
try {
const res = await fetch(`${API_BASE}/api/v1/sales/my?size=20&sort=saleDate,desc`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return;
const data = await res.json();
setOrders(data.content ?? []);
} catch { } finally {
setLoadingOrders(false);
}
}, [token]);
useEffect(() => { useEffect(() => {
if (user?.role === "CUSTOMER" || user?.role === "ADMIN") { if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
loadPets(); loadPets();
loadOrders();
} }
}, [user, loadPets]); }, [user, loadPets, loadOrders]);
useEffect(() => { useEffect(() => {
let objectUrl = null; let objectUrl = null;
@@ -642,6 +660,46 @@ export default function ProfilePage() {
)} )}
</div> </div>
)} )}
{(user.role === "CUSTOMER" || user.role === "ADMIN") && (
<div className="profile-pets-section">
<div className="profile-pets-header">
<h2 className="profile-pets-title">Order History</h2>
</div>
{loadingOrders ? (
<p className="appt-loading">Loading orders...</p>
) : orders.length === 0 ? (
<p className="profile-pets-empty">No orders yet.</p>
) : (
<div className="profile-orders-list">
{orders.map((order) => (
<div key={order.saleId} className="profile-order-card">
<div className="profile-order-header">
<span className="profile-order-date">
{new Date(order.saleDate).toLocaleDateString([], { year: "numeric", month: "short", day: "numeric" })}
</span>
<span className="profile-order-total">${Number(order.totalAmount).toFixed(2)}</span>
</div>
<div className="profile-order-meta">
<span>{order.storeName}</span>
{order.paymentMethod && <span>{order.paymentMethod}</span>}
</div>
{order.items?.length > 0 && (
<ul className="profile-order-items">
{order.items.map((item) => (
<li key={item.saleItemId}>
<span>{item.productName} × {item.quantity}</span>
<span className="profile-order-item-price">${(Number(item.unitPrice) * item.quantity).toFixed(2)}</span>
</li>
))}
</ul>
)}
</div>
))}
</div>
)}
</div>
)}
</main> </main>
); );
} }

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { createContext, useContext, useState, useRef, useCallback, useEffect } from "react"; import { createContext, useContext, useState, useRef, useCallback, useEffect } from "react";
import { createStompClient } from "@/lib/chatSocket";
const ChatWidgetContext = createContext(null); const ChatWidgetContext = createContext(null);
const API_BASE = ""; const API_BASE = "";
@@ -60,24 +61,30 @@ export function ChatWidgetProvider({ children }) {
const [liveSending, setLiveSending] = useState(false); const [liveSending, setLiveSending] = useState(false);
const [switchingToHuman, setSwitchingToHuman] = useState(false); const [switchingToHuman, setSwitchingToHuman] = useState(false);
const pollRef = useRef(null); const stompRef = useRef(null);
const activeConvIdRef = useRef(null); const activeConvIdRef = useRef(null);
const tokenRef = useRef(null); // FIX: store token so polling can restart const tokenRef = useRef(null);
const stopPolling = useCallback(() => { const disconnectStomp = useCallback(() => {
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } if (stompRef.current) {
stompRef.current.deactivate();
stompRef.current = null;
}
}, []); }, []);
const fetchLiveMessages = useCallback(async (convId, token) => { const subscribeToConversation = useCallback((client, convId) => {
if (!convId || !token) return; client.subscribe(`/topic/chat/conversations/${convId}`, (frame) => {
try { try {
const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, { const msg = JSON.parse(frame.body);
headers: { Authorization: `Bearer ${token}` }, setLiveMessages((prev) => prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]);
}); } catch { /* silent */ }
if (!res.ok) return; });
const data = await res.json(); client.subscribe(`/user/queue/chat/conversations`, (frame) => {
if (Array.isArray(data)) setLiveMessages(data); try {
} catch { /* silent */ } const conv = JSON.parse(frame.body);
if (conv.id === convId) setActiveConv(conv);
} catch { /* silent */ }
});
}, []); }, []);
const loadConversations = useCallback(async (token) => { const loadConversations = useCallback(async (token) => {
@@ -97,21 +104,35 @@ export function ChatWidgetProvider({ children }) {
const openLiveConversation = useCallback(async (convId, token) => { const openLiveConversation = useCallback(async (convId, token) => {
if (!convId || !token) return; if (!convId || !token) return;
stopPolling(); disconnectStomp();
tokenRef.current = token; // FIX: save token for polling restart tokenRef.current = token;
setActiveConvId(convId); setActiveConvId(convId);
activeConvIdRef.current = convId; activeConvIdRef.current = convId;
setLiveMessages([]); setLiveMessages([]);
setView("live"); setView("live");
try { try {
const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}`, { const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}`, {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}); });
if (res.ok) setActiveConv(await res.json()); if (res.ok) setActiveConv(await res.json());
} catch { /* silent */ } } catch { /* silent */ }
await fetchLiveMessages(convId, token);
pollRef.current = setInterval(() => fetchLiveMessages(convId, token), 2500); try {
}, [stopPolling, fetchLiveMessages]); const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
if (Array.isArray(data)) setLiveMessages(data);
}
} catch { /* silent */ }
const client = createStompClient(token);
client.onConnect = () => subscribeToConversation(client, convId);
stompRef.current = client;
client.activate();
}, [disconnectStomp, subscribeToConversation]);
const sendLiveMessage = useCallback(async (text, token, convId) => { const sendLiveMessage = useCallback(async (text, token, convId) => {
if (!text.trim() || liveSending || !token || !convId) return; if (!text.trim() || liveSending || !token || !convId) return;
@@ -122,11 +143,14 @@ export function ChatWidgetProvider({ children }) {
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ content: text }), body: JSON.stringify({ content: text }),
}); });
if (res.ok) await fetchLiveMessages(convId, token); if (res.ok) {
const msg = await res.json();
setLiveMessages((prev) => prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]);
}
} catch { /* silent */ } finally { } catch { /* silent */ } finally {
setLiveSending(false); setLiveSending(false);
} }
}, [liveSending, fetchLiveMessages]); }, [liveSending]);
const startLiveChat = useCallback(async (token) => { const startLiveChat = useCallback(async (token) => {
if (!token || switchingToHuman) return; if (!token || switchingToHuman) return;
@@ -149,19 +173,22 @@ export function ChatWidgetProvider({ children }) {
} }
}, [switchingToHuman, openLiveConversation]); }, [switchingToHuman, openLiveConversation]);
// FIX: Single effect that handles both stopping AND restarting polling
useEffect(() => { useEffect(() => {
if (!isOpen || view !== "live") { if (!isOpen || view !== "live") {
stopPolling(); disconnectStomp();
} else if (isOpen && view === "live" && activeConvIdRef.current && tokenRef.current) { } else if (isOpen && view === "live" && activeConvIdRef.current && tokenRef.current && !stompRef.current) {
stopPolling(); const convId = activeConvIdRef.current;
fetchLiveMessages(activeConvIdRef.current, tokenRef.current); const token = tokenRef.current;
pollRef.current = setInterval( const client = createStompClient(token);
() => fetchLiveMessages(activeConvIdRef.current, tokenRef.current), client.onConnect = () => subscribeToConversation(client, convId);
2500 stompRef.current = client;
); client.activate();
} }
}, [isOpen, view, stopPolling, fetchLiveMessages]); }, [isOpen, view, disconnectStomp, subscribeToConversation]);
useEffect(() => {
return () => disconnectStomp();
}, [disconnectStomp]);
const toggleOpen = useCallback(() => setIsOpen((o) => !o), []); const toggleOpen = useCallback(() => setIsOpen((o) => !o), []);
const openView = useCallback((v) => setView(v), []); const openView = useCallback((v) => setView(v), []);

16
web/lib/chatSocket.js Normal file
View File

@@ -0,0 +1,16 @@
import { Client } from "@stomp/stompjs";
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || "";
export function createStompClient(token) {
return new Client({
webSocketFactory: () => {
const SockJS = require("sockjs-client");
return new SockJS(`${BACKEND_URL}/ws/chat-sockjs`);
},
connectHeaders: { Authorization: `Bearer ${token}` },
reconnectDelay: 5000,
heartbeatIncoming: 10000,
heartbeatOutgoing: 10000,
});
}

137
web/package-lock.json generated
View File

@@ -8,11 +8,13 @@
"name": "threaded-pets", "name": "threaded-pets",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@stomp/stompjs": "^7.3.0",
"@stripe/react-stripe-js": "^3.1.1", "@stripe/react-stripe-js": "^3.1.1",
"@stripe/stripe-js": "^5.5.0", "@stripe/stripe-js": "^5.5.0",
"next": "^16.2.2", "next": "^16.2.2",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3",
"sockjs-client": "^1.6.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@@ -1232,6 +1234,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@stomp/stompjs": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-7.3.0.tgz",
"integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==",
"license": "Apache-2.0"
},
"node_modules/@stripe/react-stripe-js": { "node_modules/@stripe/react-stripe-js": {
"version": "3.10.0", "version": "3.10.0",
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-3.10.0.tgz", "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-3.10.0.tgz",
@@ -3474,6 +3482,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/eventsource": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz",
"integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -3535,6 +3552,18 @@
"reusify": "^1.0.4" "reusify": "^1.0.4"
} }
}, },
"node_modules/faye-websocket": {
"version": "0.11.4",
"resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz",
"integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==",
"license": "Apache-2.0",
"dependencies": {
"websocket-driver": ">=0.5.1"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/file-entry-cache": { "node_modules/file-entry-cache": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -3920,6 +3949,12 @@
"hermes-estree": "0.25.1" "hermes-estree": "0.25.1"
} }
}, },
"node_modules/http-parser-js": {
"version": "0.5.10",
"resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz",
"integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==",
"license": "MIT"
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -3957,6 +3992,12 @@
"node": ">=0.8.19" "node": ">=0.8.19"
} }
}, },
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/internal-slot": { "node_modules/internal-slot": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -4932,7 +4973,6 @@
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
@@ -5402,6 +5442,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
"license": "MIT"
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -5494,6 +5540,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"license": "MIT"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -5590,6 +5642,26 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-push-apply": { "node_modules/safe-push-apply": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@@ -5847,6 +5919,34 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/sockjs-client": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/sockjs-client/-/sockjs-client-1.6.1.tgz",
"integrity": "sha512-2g0tjOR+fRs0amxENLi/q5TiJTqY+WXFOzb5UwXndlK6TO3U/mirZznpx6w34HVMoc3g7cY24yC/ZMIYnDlfkw==",
"license": "MIT",
"dependencies": {
"debug": "^3.2.7",
"eventsource": "^2.0.2",
"faye-websocket": "^0.11.4",
"inherits": "^2.0.4",
"url-parse": "^1.5.10"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://tidelift.com/funding/github/npm/sockjs-client"
}
},
"node_modules/sockjs-client/node_modules/debug": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.1"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -6414,6 +6514,39 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/url-parse": {
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"license": "MIT",
"dependencies": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
}
},
"node_modules/websocket-driver": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz",
"integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==",
"license": "Apache-2.0",
"dependencies": {
"http-parser-js": ">=0.5.1",
"safe-buffer": ">=5.1.0",
"websocket-extensions": ">=0.1.1"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/websocket-extensions": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz",
"integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -9,11 +9,13 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@stomp/stompjs": "^7.3.0",
"@stripe/react-stripe-js": "^3.1.1", "@stripe/react-stripe-js": "^3.1.1",
"@stripe/stripe-js": "^5.5.0", "@stripe/stripe-js": "^5.5.0",
"next": "^16.2.2", "next": "^16.2.2",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3",
"sockjs-client": "^1.6.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",