merge main into branch
This commit is contained in:
69
.github/workflows/deploy.yml
vendored
69
.github/workflows/deploy.yml
vendored
@@ -9,21 +9,53 @@ env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
build-backend:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Set image names (lowercase)
|
||||
- name: Set image name (lowercase)
|
||||
run: |
|
||||
OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
|
||||
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
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
@@ -33,12 +65,8 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push backend image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./backend
|
||||
push: true
|
||||
tags: ${{ env.BACKEND_IMAGE }}:latest
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push frontend image
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -46,8 +74,21 @@ jobs:
|
||||
context: ./web
|
||||
push: true
|
||||
tags: ${{ env.FRONTEND_IMAGE }}:latest
|
||||
build-args: |
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }}
|
||||
no-cache: true
|
||||
|
||||
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
|
||||
uses: azure/login@v2
|
||||
@@ -61,11 +102,13 @@ jobs:
|
||||
az containerapp update \
|
||||
--name ${{ secrets.AZURE_BACKEND_APP_NAME }} \
|
||||
--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
|
||||
run: |
|
||||
az containerapp update \
|
||||
--name ${{ secrets.AZURE_FRONTEND_APP_NAME }} \
|
||||
--resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \
|
||||
--image ${{ env.FRONTEND_IMAGE }}:latest
|
||||
--image ${{ env.FRONTEND_IMAGE }}:latest \
|
||||
--revision-suffix r${{ github.run_number }}
|
||||
|
||||
@@ -8,6 +8,8 @@ import androidx.lifecycle.ViewModel;
|
||||
import com.example.petstoremobile.dtos.ActivityLogDTO;
|
||||
import com.example.petstoremobile.dtos.DropdownDTO;
|
||||
import com.example.petstoremobile.repositories.ActivityLogRepository;
|
||||
|
||||
import java.util.Locale;
|
||||
import com.example.petstoremobile.repositories.StoreRepository;
|
||||
import com.example.petstoremobile.utils.Resource;
|
||||
|
||||
@@ -89,7 +91,7 @@ public class ActivityLogListViewModel extends ViewModel {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
JWT_SECRET=<run: openssl rand -base64 32>
|
||||
STRIPE_SECRET_KEY=sk_test_...
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
|
||||
OPENROUTER_API_KEY=sk-or-v1-...
|
||||
RESEND_API_KEY=re_...
|
||||
RESEND_API_KEY=re_...
|
||||
RESEND_FROM=PetShop <no-reply@yourdomain.com>
|
||||
@@ -10,4 +10,4 @@ FROM eclipse-temurin:25-jre
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/target/*.jar app.jar
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["java","-jar","app.jar"]
|
||||
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-XX:+UseG1GC", "-jar", "app.jar"]
|
||||
|
||||
145
backend/backend-test-results.txt
Normal file
145
backend/backend-test-results.txt
Normal 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
|
||||
@@ -38,6 +38,16 @@
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</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>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
|
||||
@@ -4,10 +4,12 @@ import com.petshop.backend.config.FlywayContextInitializer;
|
||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.data.web.config.EnableSpringDataWebSupport;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
@EnableAsync
|
||||
@EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO)
|
||||
public class BackendApplication {
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -10,7 +10,7 @@ public class ActivityLoggingFilterRegistrationConfig {
|
||||
@Bean
|
||||
public FilterRegistrationBean<ActivityLoggingFilter> activityLoggingFilterRegistration(ActivityLoggingFilter activityLoggingFilter) {
|
||||
FilterRegistrationBean<ActivityLoggingFilter> registrationBean = new FilterRegistrationBean<>(activityLoggingFilter);
|
||||
registrationBean.setEnabled(false);
|
||||
registrationBean.setEnabled(true);
|
||||
return registrationBean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import com.petshop.backend.entity.StoreLocation;
|
||||
import com.petshop.backend.entity.User;
|
||||
import com.petshop.backend.repository.UserRepository;
|
||||
import com.petshop.backend.security.JwtUtil;
|
||||
import com.petshop.backend.security.UserAuthCacheService;
|
||||
import com.petshop.backend.service.ActivityLogService;
|
||||
import com.petshop.backend.service.AvatarStorageService;
|
||||
import com.petshop.backend.service.EmailService;
|
||||
@@ -58,8 +59,9 @@ public class AuthController {
|
||||
private final ActivityLogService activityLogService;
|
||||
private final PasswordResetService passwordResetService;
|
||||
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.userRepository = userRepository;
|
||||
this.jwtUtil = jwtUtil;
|
||||
@@ -68,6 +70,7 @@ public class AuthController {
|
||||
this.activityLogService = activityLogService;
|
||||
this.passwordResetService = passwordResetService;
|
||||
this.emailService = emailService;
|
||||
this.userAuthCacheService = userAuthCacheService;
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
@@ -263,6 +266,7 @@ public class AuthController {
|
||||
error.put("message", "Username, email, or phone already exists");
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
|
||||
}
|
||||
userAuthCacheService.evict(updatedUser.getId());
|
||||
return ResponseEntity.ok(toUserInfoResponse(updatedUser));
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.petshop.backend.controller;
|
||||
import com.petshop.backend.dto.sale.SaleRequest;
|
||||
import com.petshop.backend.dto.sale.SaleResponse;
|
||||
import com.petshop.backend.service.SaleService;
|
||||
import com.petshop.backend.util.AuthenticationHelper;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
@@ -21,6 +22,13 @@ public class SaleController {
|
||||
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
|
||||
@PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
|
||||
public ResponseEntity<Page<SaleResponse>> getAllSales(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
package com.petshop.backend.dto.chat;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public class ConversationRequest {
|
||||
@NotBlank(message = "Initial message is required")
|
||||
private String message;
|
||||
|
||||
public ConversationRequest() {
|
||||
|
||||
@@ -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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -41,11 +41,11 @@ public interface PetRepository extends JpaRepository<Pet, Long> {
|
||||
Optional<Pet> findByIdAndOwner_Id(Long id, Long ownerId);
|
||||
|
||||
@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);
|
||||
|
||||
@Query("SELECT p FROM Pet p 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 " +
|
||||
@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, '%')) 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 " +
|
||||
"(:breed IS NULL OR LOWER(COALESCE(p.petBreed, '')) = LOWER(:breed)) AND " +
|
||||
"(:status IS NULL OR LOWER(p.petStatus) = LOWER(:status)) AND " +
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.petshop.backend.security;
|
||||
|
||||
import com.petshop.backend.entity.User;
|
||||
import com.petshop.backend.exception.ApiErrorResponder;
|
||||
import com.petshop.backend.repository.UserRepository;
|
||||
import io.jsonwebtoken.JwtException;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
@@ -16,16 +15,18 @@ import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Date;
|
||||
|
||||
@Component
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtUtil jwtUtil;
|
||||
private final UserRepository userRepository;
|
||||
private final UserAuthCacheService userAuthCacheService;
|
||||
private final ApiErrorResponder apiErrorResponder;
|
||||
|
||||
public JwtAuthenticationFilter(JwtUtil jwtUtil, UserRepository userRepository, ApiErrorResponder apiErrorResponder) {
|
||||
public JwtAuthenticationFilter(JwtUtil jwtUtil, UserAuthCacheService userAuthCacheService, ApiErrorResponder apiErrorResponder) {
|
||||
this.jwtUtil = jwtUtil;
|
||||
this.userRepository = userRepository;
|
||||
this.userAuthCacheService = userAuthCacheService;
|
||||
this.apiErrorResponder = apiErrorResponder;
|
||||
}
|
||||
|
||||
@@ -44,30 +45,44 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
jwt = authHeader.substring(7);
|
||||
Long userId;
|
||||
String username;
|
||||
String roleStr;
|
||||
Integer jwtTokenVersion;
|
||||
try {
|
||||
userId = jwtUtil.extractUserId(jwt);
|
||||
username = jwtUtil.extractUsername(jwt);
|
||||
roleStr = jwtUtil.extractRole(jwt);
|
||||
jwtTokenVersion = jwtUtil.extractTokenVersion(jwt);
|
||||
} catch (JwtException | IllegalArgumentException ex) {
|
||||
writeUnauthorized(request, response, "Invalid or expired token", ex);
|
||||
return;
|
||||
}
|
||||
|
||||
if (userId != null && SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||
User user = userRepository.findById(userId).orElse(null);
|
||||
if (user == null || user.getActive() == null || !user.getActive()) {
|
||||
writeUnauthorized(request, response, "User account is inactive", null);
|
||||
return;
|
||||
}
|
||||
if (!jwtUtil.validateToken(jwt, user)) {
|
||||
if (jwtUtil.extractExpiration(jwt).before(new Date())) {
|
||||
writeUnauthorized(request, response, "Invalid or expired token", null);
|
||||
return;
|
||||
}
|
||||
|
||||
AppPrincipal principal = new AppPrincipal(
|
||||
user.getId(),
|
||||
user.getUsername(),
|
||||
user.getRole(),
|
||||
user.getTokenVersion()
|
||||
);
|
||||
UserAuthCacheService.UserAuthData authData = userAuthCacheService.loadAuthData(userId);
|
||||
if (authData == null || !Boolean.TRUE.equals(authData.active())) {
|
||||
writeUnauthorized(request, response, "User account is inactive", null);
|
||||
return;
|
||||
}
|
||||
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(
|
||||
principal,
|
||||
null,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.petshop.backend.security;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
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.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import com.petshop.backend.config.ActivityLoggingFilter;
|
||||
|
||||
import java.util.List;
|
||||
@@ -30,6 +33,9 @@ import java.util.List;
|
||||
@EnableMethodSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
@Value("${app.allowed-origins}")
|
||||
private String allowedOriginsRaw;
|
||||
|
||||
private final JwtAuthenticationFilter jwtAuthFilter;
|
||||
private final RateLimitFilter rateLimitFilter;
|
||||
private final UserDetailsService userDetailsService;
|
||||
@@ -101,13 +107,13 @@ public class SecurityConfig {
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
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.setAllowedHeaders(List.of("*"));
|
||||
config.setAllowCredentials(true);
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", config);
|
||||
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,65 @@
|
||||
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.User;
|
||||
import com.petshop.backend.repository.ActivityLogRepository;
|
||||
import com.petshop.backend.repository.UserRepository;
|
||||
import jakarta.persistence.criteria.Predicate;
|
||||
import org.slf4j.Logger;
|
||||
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.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
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;
|
||||
|
||||
public ActivityLogService(UserRepository userRepository) {
|
||||
public ActivityLogService(ActivityLogRepository activityLogRepository, UserRepository userRepository) {
|
||||
this.activityLogRepository = activityLogRepository;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void record(Long userId, String activity) {
|
||||
if (userId == null || activity == null || activity.isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
User user = userRepository.findById(userId).orElse(null);
|
||||
if (user == null) {
|
||||
User managedUser = userRepository.findById(userId).orElse(null);
|
||||
if (managedUser == null) {
|
||||
return;
|
||||
}
|
||||
StoreLocation store = user.getPrimaryStore();
|
||||
String role = user.getRole() != null ? user.getRole().name() : "UNKNOWN";
|
||||
String storeName = store != null ? store.getStoreName() : "no store";
|
||||
log.info("{} | {} | {} | {}", role, user.getUsername(), storeName, activity.trim());
|
||||
StoreLocation store = managedUser.getPrimaryStore();
|
||||
ActivityLog entry = new ActivityLog();
|
||||
entry.setUser(managedUser);
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,8 +127,6 @@ public class AppointmentService {
|
||||
}
|
||||
}
|
||||
|
||||
validateSpeciesServiceCompatibility(pet, service);
|
||||
|
||||
validateStoreAccess(store.getStoreId(), authenticatedUser);
|
||||
validatePetServiceCompatibility(pet, service);
|
||||
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) {
|
||||
if (user.getRole() != User.Role.STAFF) {
|
||||
return;
|
||||
|
||||
@@ -59,14 +59,20 @@ public class ChatService {
|
||||
conversation.setMode(Conversation.ConversationMode.AUTOMATED);
|
||||
conversation = conversationRepository.save(conversation);
|
||||
|
||||
Message message = new Message();
|
||||
message.setConversationId(conversation.getId());
|
||||
message.setSenderId(userId);
|
||||
message.setContent(request.getMessage());
|
||||
message.setIsRead(false);
|
||||
messageRepository.save(message);
|
||||
User botUser = getBotUser();
|
||||
String firstName = user.getFirstName();
|
||||
String greeting = (firstName != null && !firstName.isBlank())
|
||||
? "Hi " + firstName + "! I'm Leon's Pet Assistant. Ask me anything about pet care, adoption advice, or your pets."
|
||||
: "Hi! I'm Leon's Pet Assistant. Ask me anything about pet care, adoption advice, or your pets.";
|
||||
|
||||
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) {
|
||||
|
||||
@@ -5,8 +5,10 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||
import com.petshop.backend.entity.Conversation;
|
||||
import com.petshop.backend.entity.Message;
|
||||
import com.petshop.backend.entity.Pet;
|
||||
import com.petshop.backend.entity.User;
|
||||
import com.petshop.backend.repository.MessageRepository;
|
||||
import com.petshop.backend.repository.PetRepository;
|
||||
import com.petshop.backend.repository.UserRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -31,7 +33,7 @@ public class OpenRouterAiService {
|
||||
@Value("${openrouter.api-key:}")
|
||||
private String apiKey;
|
||||
|
||||
@Value("${openrouter.model:openai/gpt-oss-120b:free}")
|
||||
@Value("${openrouter.model:google/gemma-4-31b-it:free}")
|
||||
private String model;
|
||||
|
||||
private final String openRouterUrl = "https://openrouter.ai/api/v1/chat/completions";
|
||||
@@ -39,6 +41,7 @@ public class OpenRouterAiService {
|
||||
private final ChatRealtimeService chatRealtimeService;
|
||||
private final MessageRepository messageRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final PetRepository petRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final HttpClient httpClient;
|
||||
|
||||
@@ -46,12 +49,14 @@ public class OpenRouterAiService {
|
||||
ChatService chatService,
|
||||
ChatRealtimeService chatRealtimeService,
|
||||
MessageRepository messageRepository,
|
||||
UserRepository userRepository
|
||||
UserRepository userRepository,
|
||||
PetRepository petRepository
|
||||
) {
|
||||
this.chatService = chatService;
|
||||
this.chatRealtimeService = chatRealtimeService;
|
||||
this.messageRepository = messageRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.petRepository = petRepository;
|
||||
this.objectMapper = JsonMapper.builder().findAndAddModules().build();
|
||||
this.httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
@@ -117,10 +122,15 @@ public class OpenRouterAiService {
|
||||
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<>();
|
||||
messages.add(Map.of(
|
||||
"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) {
|
||||
@@ -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) {
|
||||
if (message.getSenderId() != null && message.getSenderId().equals(botUserId)) {
|
||||
return "assistant";
|
||||
|
||||
@@ -24,7 +24,7 @@ public class OpenRouterService {
|
||||
@Value("${openrouter.api-key:}")
|
||||
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 final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.petshop.backend.entity.User;
|
||||
import com.petshop.backend.exception.BusinessException;
|
||||
import com.petshop.backend.repository.PasswordResetTokenRepository;
|
||||
import com.petshop.backend.repository.UserRepository;
|
||||
import com.petshop.backend.security.UserAuthCacheService;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -29,16 +30,19 @@ public class PasswordResetService {
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final EmailService emailService;
|
||||
private final UserAuthCacheService userAuthCacheService;
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
public PasswordResetService(PasswordResetTokenRepository passwordResetTokenRepository,
|
||||
UserRepository userRepository,
|
||||
PasswordEncoder passwordEncoder,
|
||||
EmailService emailService) {
|
||||
EmailService emailService,
|
||||
UserAuthCacheService userAuthCacheService) {
|
||||
this.passwordResetTokenRepository = passwordResetTokenRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.emailService = emailService;
|
||||
this.userAuthCacheService = userAuthCacheService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -97,6 +101,7 @@ public class PasswordResetService {
|
||||
user.setPassword(passwordEncoder.encode(newPassword));
|
||||
user.setTokenVersion(user.getTokenVersion() + 1);
|
||||
userRepository.save(user);
|
||||
userAuthCacheService.evict(user.getId());
|
||||
|
||||
token.setUsedAt(now);
|
||||
passwordResetTokenRepository.save(token);
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.petshop.backend.entity.User;
|
||||
import com.petshop.backend.exception.ResourceNotFoundException;
|
||||
import com.petshop.backend.repository.StoreRepository;
|
||||
import com.petshop.backend.repository.UserRepository;
|
||||
import com.petshop.backend.security.UserAuthCacheService;
|
||||
import com.petshop.backend.util.AuthenticationHelper;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
@@ -33,11 +34,13 @@ public class UserService {
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
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.passwordEncoder = passwordEncoder;
|
||||
this.storeRepository = storeRepository;
|
||||
this.userAuthCacheService = userAuthCacheService;
|
||||
}
|
||||
|
||||
public Page<UserResponse> getAllUsers(String query, String role, Pageable pageable) {
|
||||
@@ -147,6 +150,7 @@ public class UserService {
|
||||
}
|
||||
|
||||
user = userRepository.save(user);
|
||||
userAuthCacheService.evict(user.getId());
|
||||
return mapToResponse(user);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ spring:
|
||||
username: ${SPRING_DATASOURCE_USERNAME:petshop}
|
||||
password: ${SPRING_DATASOURCE_PASSWORD:petshop}
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
hikari:
|
||||
maximum-pool-size: 20
|
||||
minimum-idle: 5
|
||||
connection-timeout: 30000
|
||||
|
||||
sql:
|
||||
init:
|
||||
@@ -46,14 +50,16 @@ server:
|
||||
springdoc:
|
||||
api-docs:
|
||||
path: /v3/api-docs
|
||||
enabled: ${SWAGGER_ENABLED:false}
|
||||
swagger-ui:
|
||||
path: /swagger-ui
|
||||
enabled: ${SWAGGER_ENABLED:false}
|
||||
|
||||
app:
|
||||
upload:
|
||||
base-dir: ${UPLOAD_BASE_DIR:uploads}
|
||||
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:
|
||||
storage:
|
||||
@@ -82,9 +88,3 @@ logging:
|
||||
com.petshop: ${LOG_LEVEL:INFO}
|
||||
org.springframework.security: ${LOG_LEVEL_SECURITY:WARN}
|
||||
org.springdoc.core.events.SpringDocAppInitializer: ERROR
|
||||
|
||||
jackson:
|
||||
serialization:
|
||||
write-dates-as-timestamps: false
|
||||
deserialization:
|
||||
fail-on-unknown-properties: false
|
||||
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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);
|
||||
@@ -8,6 +8,7 @@ import com.petshop.backend.entity.User;
|
||||
import com.petshop.backend.repository.StoreRepository;
|
||||
import com.petshop.backend.repository.UserRepository;
|
||||
import com.petshop.backend.security.AppPrincipal;
|
||||
import com.petshop.backend.security.UserAuthCacheService;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -38,12 +39,13 @@ class UserServiceTest {
|
||||
@Mock private UserRepository userRepository;
|
||||
@Mock private PasswordEncoder passwordEncoder;
|
||||
@Mock private StoreRepository storeRepository;
|
||||
@Mock private UserAuthCacheService userAuthCacheService;
|
||||
|
||||
private UserService userService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
userService = new UserService(userRepository, passwordEncoder, storeRepository);
|
||||
userService = new UserService(userRepository, passwordEncoder, storeRepository, userAuthCacheService);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
|
||||
@@ -90,8 +90,9 @@ public class ChatRealtimeClient implements WebSocket.Listener {
|
||||
for (ConversationResponse conv : globalConversations.values()) {
|
||||
if ("CLOSED".equals(conv.getStatus())) continue;
|
||||
|
||||
// Needs pickup
|
||||
if (conv.getHumanRequestedAt() != null && conv.getStaffId() == null) {
|
||||
// Needs pickup - only if we haven't already replied
|
||||
if (conv.getHumanRequestedAt() != null && conv.getStaffId() == null
|
||||
&& (currentUserId == null || !currentUserId.equals(conv.getLastSenderId()))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
2
web/.dockerignore
Normal file
2
web/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
.next
|
||||
@@ -1,4 +1,5 @@
|
||||
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=http://localhost:8080
|
||||
#BACKEND_URL=https://petshop-backend.nicepond-c7280126.westus2.azurecontainerapps.io
|
||||
|
||||
@@ -3,8 +3,8 @@ WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51TK18lFQ95OLlFb7dNtKXlvhry8IOvHaWJWW7zUNFhicMgyJ2EgAFhiAocxsCyP95IKt7AeQg4cWe5iHF3qoheYl0034Cd4yij
|
||||
ENV NEXT_PUBLIC_BACKEND_URL=https://petshop-backend.nicepond-c7280126.westus2.azurecontainerapps.io
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine
|
||||
|
||||
@@ -74,7 +74,7 @@ export default function AdoptPage() {
|
||||
[pets]
|
||||
);
|
||||
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
const ITEMS_PER_PAGE = 24;
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
|
||||
const filteredPets = useMemo(
|
||||
@@ -192,15 +192,42 @@ export default function AdoptPage() {
|
||||
<div className="pagination-controls">
|
||||
<button
|
||||
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}
|
||||
>
|
||||
← Prev
|
||||
</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
|
||||
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}
|
||||
>
|
||||
Next →
|
||||
|
||||
@@ -4,9 +4,69 @@ import dynamic from "next/dynamic";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { createStompClient } from "@/lib/chatSocket";
|
||||
|
||||
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() {
|
||||
const { user, token, loading: authLoading } = useAuth();
|
||||
@@ -22,15 +82,20 @@ function AiChatPage() {
|
||||
const [error, setError] = useState(null);
|
||||
const [conversations, setConversations] = useState([]);
|
||||
const [convsLoading, setConvsLoading] = useState(false);
|
||||
const [closedExpanded, setClosedExpanded] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
const [botTyping, setBotTyping] = useState(false);
|
||||
const [switchingConv, setSwitchingConv] = useState(false);
|
||||
|
||||
const messagesEndRef = useRef(null);
|
||||
const messagesAreaRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
const pollRef = useRef(null);
|
||||
const stompRef = useRef(null);
|
||||
const lastMessageIdRef = useRef(null);
|
||||
const fileInputRef = useRef(null);
|
||||
const lastScrolledIdRef = useRef(null);
|
||||
const initialLoadDoneRef = useRef(false);
|
||||
const botTypingTimeoutRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
@@ -42,6 +107,7 @@ function AiChatPage() {
|
||||
if (messages.length === 0) return;
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
if (lastMsg.id === lastScrolledIdRef.current) return;
|
||||
if (!initialLoadDoneRef.current) return;
|
||||
lastScrolledIdRef.current = lastMsg.id;
|
||||
const area = messagesAreaRef.current;
|
||||
if (!area) return;
|
||||
@@ -51,8 +117,17 @@ function AiChatPage() {
|
||||
}
|
||||
}, [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) => {
|
||||
if (!token || !convId) return;
|
||||
initialLoadDoneRef.current = false;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
@@ -61,10 +136,17 @@ function AiChatPage() {
|
||||
const data = await res.json();
|
||||
if (Array.isArray(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 {
|
||||
// silent
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
@@ -100,38 +182,36 @@ function AiChatPage() {
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const startPolling = useCallback((convId) => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
pollRef.current = setInterval(async () => {
|
||||
if (!token || !convId) return;
|
||||
try {
|
||||
const [msgsRes, convRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}),
|
||||
fetch(`${API_BASE}/api/v1/chat/conversations/${convId}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}),
|
||||
]);
|
||||
if (msgsRes.ok) {
|
||||
const data = await msgsRes.json();
|
||||
if (Array.isArray(data)) {
|
||||
const lastId = data.length > 0 ? data[data.length - 1].id : null;
|
||||
if (lastId !== lastMessageIdRef.current) {
|
||||
lastMessageIdRef.current = lastId;
|
||||
setMessages(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (convRes.ok) {
|
||||
const convData = await convRes.json();
|
||||
setConversation(convData);
|
||||
}
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}, POLL_INTERVAL);
|
||||
}, [token]);
|
||||
const connectStomp = useCallback((convId) => {
|
||||
if (stompRef.current) {
|
||||
stompRef.current.deactivate();
|
||||
stompRef.current = null;
|
||||
}
|
||||
const client = createStompClient(token);
|
||||
client.onConnect = () => {
|
||||
client.subscribe(`/topic/chat/conversations/${convId}`, (frame) => {
|
||||
try {
|
||||
const msg = JSON.parse(frame.body);
|
||||
setMessages((prev) => prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]);
|
||||
lastMessageIdRef.current = msg.id;
|
||||
if (botTypingTimeoutRef.current) clearTimeout(botTypingTimeoutRef.current);
|
||||
setBotTyping(false);
|
||||
} catch { /* silent */ }
|
||||
});
|
||||
const convTopic = user?.role === "CUSTOMER"
|
||||
? `/user/queue/chat/conversations`
|
||||
: `/topic/chat/conversations`;
|
||||
client.subscribe(convTopic, (frame) => {
|
||||
try {
|
||||
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 */ }
|
||||
});
|
||||
};
|
||||
stompRef.current = client;
|
||||
client.activate();
|
||||
}, [token, user?.role]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || authLoading) return;
|
||||
@@ -171,7 +251,7 @@ function AiChatPage() {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ message: "Hello! I'd like to chat with the AI assistant." }),
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (stale) return;
|
||||
if (res.ok) {
|
||||
@@ -197,7 +277,7 @@ function AiChatPage() {
|
||||
]);
|
||||
if (stale) return;
|
||||
setLoadingConv(false);
|
||||
startPolling(convId);
|
||||
connectStomp(convId);
|
||||
router.replace(`/ai-chat?id=${convId}`, { scroll: false });
|
||||
}
|
||||
|
||||
@@ -205,9 +285,9 @@ function AiChatPage() {
|
||||
|
||||
return () => {
|
||||
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) {
|
||||
e?.preventDefault();
|
||||
@@ -250,6 +330,11 @@ function AiChatPage() {
|
||||
const msg = await res.json();
|
||||
setMessages((prev) => prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]);
|
||||
lastMessageIdRef.current = msg.id;
|
||||
if (!isEscalated) {
|
||||
setBotTyping(true);
|
||||
if (botTypingTimeoutRef.current) clearTimeout(botTypingTimeoutRef.current);
|
||||
botTypingTimeoutRef.current = setTimeout(() => setBotTyping(false), 30000);
|
||||
}
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
setInput(text);
|
||||
@@ -320,7 +405,7 @@ function AiChatPage() {
|
||||
}
|
||||
|
||||
async function handleNewConversation() {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; }
|
||||
setError(null);
|
||||
setLoadingConv(true);
|
||||
try {
|
||||
@@ -330,7 +415,7 @@ function AiChatPage() {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ message: "Hello! I'd like to chat with the AI assistant." }),
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
@@ -342,7 +427,7 @@ function AiChatPage() {
|
||||
setConversation(conv);
|
||||
await Promise.all([fetchMessages(conv.id), fetchConversations()]);
|
||||
setLoadingConv(false);
|
||||
startPolling(conv.id);
|
||||
connectStomp(conv.id);
|
||||
router.replace(`/ai-chat?id=${conv.id}`, { scroll: false });
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
@@ -351,9 +436,10 @@ function AiChatPage() {
|
||||
}
|
||||
|
||||
async function switchConversation(convId) {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; }
|
||||
setMessages([]);
|
||||
setError(null);
|
||||
setBotTyping(false);
|
||||
router.replace(`/ai-chat?id=${convId}`, { scroll: false });
|
||||
}
|
||||
|
||||
@@ -364,12 +450,28 @@ function AiChatPage() {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
router.push(`/chat?id=${conversation.id}`);
|
||||
setConversation((prev) => prev ? { ...prev, mode: "HUMAN" } : prev);
|
||||
} catch {
|
||||
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) {
|
||||
return (
|
||||
<main style={s.page}>
|
||||
@@ -382,6 +484,8 @@ function AiChatPage() {
|
||||
|
||||
const isEscalated = conversation?.mode === "HUMAN";
|
||||
const isClosed = conversation?.status === "CLOSED";
|
||||
const hasStaff = !!conversation?.staffId;
|
||||
const hasStaffMessage = messages.some((m) => m.senderId !== user?.id);
|
||||
|
||||
return (
|
||||
<main style={s.page}>
|
||||
@@ -402,7 +506,7 @@ function AiChatPage() {
|
||||
<p style={s.sidebarEmpty}>No conversations yet.</p>
|
||||
)}
|
||||
<div style={{ overflowY: "auto", flex: 1 }}>
|
||||
{conversations.map((conv) => (
|
||||
{conversations.filter(c => c.status !== "CLOSED").map((conv) => (
|
||||
<button
|
||||
key={conv.id}
|
||||
style={{ ...s.convItem, ...(conv.id === conversation?.id ? s.convItemActive : {}) }}
|
||||
@@ -410,9 +514,7 @@ function AiChatPage() {
|
||||
>
|
||||
<div style={s.convItemTop}>
|
||||
<span style={s.convItemSubject}>{conv.subject || `Conversation #${conv.id}`}</span>
|
||||
<span style={{ ...s.convStatusBadge, ...(conv.status === "OPEN" ? s.convStatusOpen : s.convStatusClosed) }}>
|
||||
{conv.status}
|
||||
</span>
|
||||
<span style={{ ...s.convStatusBadge, ...s.convStatusOpen }}>{conv.status}</span>
|
||||
</div>
|
||||
<div style={s.convItemBottom}>
|
||||
<span style={s.convItemMode}>{conv.mode === "HUMAN" ? "👤 Live" : "🤖 AI"}</span>
|
||||
@@ -421,6 +523,30 @@ function AiChatPage() {
|
||||
</button>
|
||||
))}
|
||||
</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}>
|
||||
+ New Conversation
|
||||
</button>
|
||||
@@ -444,41 +570,44 @@ function AiChatPage() {
|
||||
<div style={s.chatCard}>
|
||||
<div style={s.chatHeader}>
|
||||
<div style={s.chatHeaderLeft}>
|
||||
<div style={s.aiAvatar}>🐾</div>
|
||||
<div style={isEscalated ? s.agentAvatar : s.aiAvatar}>{isEscalated ? "👤" : "🐾"}</div>
|
||||
<div>
|
||||
<div style={s.chatHeaderTitle}>Leon's Pet Assistant</div>
|
||||
<div style={s.chatHeaderStatus}>
|
||||
<span style={s.statusDot} /> Online
|
||||
<div style={s.chatHeaderTitle}>
|
||||
{isEscalated ? (hasStaff ? "Support Agent" : "Leon's Pet Store Support") : "Leon's Pet Assistant"}
|
||||
</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 style={{ display: "flex", gap: "0.5rem" }}>
|
||||
{!isEscalated && !isClosed && (
|
||||
<button
|
||||
style={s.humanBtn}
|
||||
onClick={handleSwitchToHuman}
|
||||
title="Connect with a human support agent"
|
||||
>
|
||||
<button style={s.humanBtn} onClick={handleSwitchToHuman} title="Connect with a human support agent">
|
||||
Chat with a Real Person
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
style={s.liveBtn}
|
||||
onClick={() => router.push("/chat")}
|
||||
title="Go to Live Support"
|
||||
>
|
||||
Live Support
|
||||
</button>
|
||||
{!isClosed && (
|
||||
<button style={s.closeConvBtn} onClick={handleCloseConversation} title="Close this conversation">
|
||||
Close Chat
|
||||
</button>
|
||||
)}
|
||||
</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}>
|
||||
{messages.length === 0 && (
|
||||
<div style={s.emptyState}>
|
||||
<div style={s.emptyIcon}>🐾</div>
|
||||
<div style={s.emptyIcon}>{isEscalated ? "💬" : "🐾"}</div>
|
||||
<p style={s.emptyText}>
|
||||
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!
|
||||
{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!`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -493,7 +622,7 @@ function AiChatPage() {
|
||||
...(isOwn ? s.messageRowUser : s.messageRowAgent),
|
||||
}}
|
||||
>
|
||||
{!isOwn && <div style={s.aiAvatarSmall}>🐾</div>}
|
||||
{!isOwn && <div style={isEscalated ? s.agentAvatarSmall : s.aiAvatarSmall}>{isEscalated ? "👤" : "🐾"}</div>}
|
||||
<div
|
||||
style={{
|
||||
...s.messageBubble,
|
||||
@@ -507,16 +636,7 @@ function AiChatPage() {
|
||||
</span>
|
||||
))}
|
||||
{msg.attachmentUrl && (
|
||||
<div style={s.attachment}>
|
||||
<a
|
||||
href={msg.attachmentUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={s.attachmentLink}
|
||||
>
|
||||
📎 {msg.attachmentName || "Attachment"}
|
||||
</a>
|
||||
</div>
|
||||
<AttachmentPreview url={msg.attachmentUrl} name={msg.attachmentName} token={token} />
|
||||
)}
|
||||
<div style={{ ...s.timestamp, ...(isOwn ? s.timestampUser : {}) }}>
|
||||
{msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : ""}
|
||||
@@ -530,6 +650,24 @@ function AiChatPage() {
|
||||
</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>
|
||||
|
||||
@@ -726,6 +864,21 @@ const s = {
|
||||
},
|
||||
convStatusOpen: { background: "#e6f9ee", color: "#1a7a3c" },
|
||||
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: {
|
||||
margin: "0.65rem 1rem",
|
||||
background: "#333",
|
||||
@@ -815,6 +968,60 @@ const s = {
|
||||
cursor: "pointer",
|
||||
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: {
|
||||
background: "white",
|
||||
borderRadius: 16,
|
||||
|
||||
@@ -20,20 +20,22 @@ const SPECIES_BREEDS = {
|
||||
Other: ["Other"],
|
||||
};
|
||||
|
||||
// Explicit allowlists for species with restricted service availability.
|
||||
// Species not listed here may use all services.
|
||||
const SPECIES_SERVICE_ALLOWLIST = {
|
||||
const SPECIES_EXCLUSIVE_SERVICES = {
|
||||
Bird: ["wing clipping", "beak and nail"],
|
||||
Fish: ["aquarium health"],
|
||||
};
|
||||
|
||||
function getAvailableServices(services, species) {
|
||||
if (!species) return services;
|
||||
const allowlist = SPECIES_SERVICE_ALLOWLIST[species];
|
||||
if (!allowlist) return services;
|
||||
return services.filter((s) =>
|
||||
allowlist.some((kw) => s.serviceName.toLowerCase().includes(kw))
|
||||
);
|
||||
return services.filter((s) => {
|
||||
const name = s.serviceName.toLowerCase();
|
||||
for (const [exclusiveSpecies, keywords] of Object.entries(SPECIES_EXCLUSIVE_SERVICES)) {
|
||||
if (exclusiveSpecies !== species && keywords.some((kw) => name.includes(kw))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
@@ -381,6 +383,10 @@ function AppointmentsPage() {
|
||||
|
||||
const [showAddPetModal, setShowAddPetModal] = useState(false);
|
||||
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";
|
||||
|
||||
@@ -964,79 +970,164 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
<h2 className="appt-form-title">{canBookAppointments ? "Your Appointments" : "Appointments"}</h2>
|
||||
{loadingAppointments ? (
|
||||
<p className="appt-loading">Loading appointments...</p>
|
||||
) : appointments.length === 0 ? (
|
||||
<p className="appt-empty">No appointments yet.</p>
|
||||
) : (
|
||||
<div className="appt-list">
|
||||
{appointments.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>
|
||||
) : (() => {
|
||||
const activeAppts = appointments.filter((a) => a.appointmentStatus?.toLowerCase() === "booked");
|
||||
const pastAppts = appointments.filter((a) => a.appointmentStatus?.toLowerCase() !== "booked");
|
||||
const q = apptSearch.toLowerCase();
|
||||
const filteredActive = activeAppts.filter((a) =>
|
||||
!q || [a.serviceName, a.storeName, a.petName].some((v) => v?.toLowerCase().includes(q))
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
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 className="appt-card-details">
|
||||
<span>{a.storeName}</span>
|
||||
<span>{a.appointmentDate} at {formatTime(a.appointmentTime)}</span>
|
||||
)}
|
||||
{pastAppts.length > 0 && (
|
||||
<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>
|
||||
{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>
|
||||
{loadingAdoptions ? (
|
||||
<p className="appt-loading">Loading adoptions...</p>
|
||||
) : adoptions.length === 0 ? (
|
||||
<p className="appt-empty">No adoption requests yet.</p>
|
||||
) : (
|
||||
<div className="appt-list">
|
||||
{adoptions.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>
|
||||
) : (() => {
|
||||
const activeAdoptions = adoptions.filter((a) => a.adoptionStatus?.toLowerCase() === "pending");
|
||||
const pastAdoptions = adoptions.filter((a) => a.adoptionStatus?.toLowerCase() !== "pending");
|
||||
const q = adoptionSearch.toLowerCase();
|
||||
const filteredActive = activeAdoptions.filter((a) =>
|
||||
!q || [a.petName, a.sourceStoreName].some((v) => v?.toLowerCase().includes(q))
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
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 className="appt-card-details">
|
||||
<span>{a.sourceStoreName}</span>
|
||||
<span>{a.adoptionDate}</span>
|
||||
)}
|
||||
{pastAdoptions.length > 0 && (
|
||||
<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>
|
||||
{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>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -57,6 +57,11 @@ function PaymentForm({ clientSecret, totalAmount, onSuccess, onCancel }) {
|
||||
<p className="cart-payment-total">
|
||||
Total to pay: <strong>${parseFloat(totalAmount).toFixed(2)}</strong>
|
||||
</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 />
|
||||
{payError && <p className="cart-error-msg">{payError}</p>}
|
||||
<div className="cart-payment-actions">
|
||||
|
||||
@@ -4,9 +4,69 @@ import dynamic from "next/dynamic";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { createStompClient } from "@/lib/chatSocket";
|
||||
|
||||
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() {
|
||||
const { user, token, loading: authLoading } = useAuth();
|
||||
@@ -22,15 +82,18 @@ function ChatPage() {
|
||||
const [error, setError] = useState(null);
|
||||
const [conversations, setConversations] = useState([]);
|
||||
const [convsLoading, setConvsLoading] = useState(false);
|
||||
const [closedExpanded, setClosedExpanded] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
const [switchingConv, setSwitchingConv] = useState(false);
|
||||
|
||||
const messagesEndRef = useRef(null);
|
||||
const messagesAreaRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
const pollRef = useRef(null);
|
||||
const stompRef = useRef(null);
|
||||
const lastMessageIdRef = useRef(null);
|
||||
const fileInputRef = useRef(null);
|
||||
const lastScrolledIdRef = useRef(null);
|
||||
const initialLoadDoneRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
@@ -42,17 +105,17 @@ function ChatPage() {
|
||||
if (messages.length === 0) return;
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
if (lastMsg.id === lastScrolledIdRef.current) return;
|
||||
if (!initialLoadDoneRef.current) return;
|
||||
lastScrolledIdRef.current = lastMsg.id;
|
||||
const area = messagesAreaRef.current;
|
||||
if (!area) return;
|
||||
const nearBottom = area.scrollHeight - area.scrollTop - area.clientHeight < 80;
|
||||
if (nearBottom) {
|
||||
area.scrollTop = area.scrollHeight;
|
||||
}
|
||||
const nearBottom = area.scrollHeight - area.scrollTop - area.clientHeight < 150;
|
||||
if (nearBottom) area.scrollTop = area.scrollHeight;
|
||||
}, [messages]);
|
||||
|
||||
const fetchMessages = useCallback(async (convId) => {
|
||||
if (!token || !convId) return;
|
||||
initialLoadDoneRef.current = false;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
@@ -67,10 +130,16 @@ function ChatPage() {
|
||||
|
||||
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 {
|
||||
setError("Failed to load messages.");
|
||||
}
|
||||
@@ -114,40 +183,34 @@ function ChatPage() {
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const startPolling = useCallback((convId) => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
pollRef.current = setInterval(async () => {
|
||||
if (!token || !convId) return;
|
||||
try {
|
||||
const [msgsRes, convRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}),
|
||||
fetch(`${API_BASE}/api/v1/chat/conversations/${convId}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}),
|
||||
]);
|
||||
if (msgsRes.ok) {
|
||||
const data = await msgsRes.json();
|
||||
if (Array.isArray(data)) {
|
||||
const lastId = data.length > 0 ? data[data.length - 1].id : null;
|
||||
if (lastId !== lastMessageIdRef.current) {
|
||||
lastMessageIdRef.current = lastId;
|
||||
setMessages(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (convRes.ok) {
|
||||
const convData = await convRes.json();
|
||||
setConversation(convData);
|
||||
}
|
||||
}
|
||||
|
||||
catch {
|
||||
//Silent
|
||||
}
|
||||
}, POLL_INTERVAL);
|
||||
}, [token]);
|
||||
const connectStomp = useCallback((convId) => {
|
||||
if (stompRef.current) {
|
||||
stompRef.current.deactivate();
|
||||
stompRef.current = null;
|
||||
}
|
||||
const client = createStompClient(token);
|
||||
client.onConnect = () => {
|
||||
client.subscribe(`/topic/chat/conversations/${convId}`, (frame) => {
|
||||
try {
|
||||
const msg = JSON.parse(frame.body);
|
||||
setMessages((prev) => prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]);
|
||||
lastMessageIdRef.current = msg.id;
|
||||
} catch { /* silent */ }
|
||||
});
|
||||
const convTopic = user?.role === "CUSTOMER"
|
||||
? `/user/queue/chat/conversations`
|
||||
: `/topic/chat/conversations`;
|
||||
client.subscribe(convTopic, (frame) => {
|
||||
try {
|
||||
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 */ }
|
||||
});
|
||||
};
|
||||
stompRef.current = client;
|
||||
client.activate();
|
||||
}, [token, user?.role]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || authLoading) return;
|
||||
@@ -194,16 +257,16 @@ function ChatPage() {
|
||||
]);
|
||||
if (stale) return;
|
||||
setLoadingConv(false);
|
||||
startPolling(convId);
|
||||
connectStomp(convId);
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
return () => {
|
||||
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) {
|
||||
e?.preventDefault();
|
||||
@@ -347,7 +410,7 @@ function ChatPage() {
|
||||
setConversation(conv);
|
||||
await Promise.all([fetchMessages(conv.id), fetchConversations()]);
|
||||
setLoadingConv(false);
|
||||
startPolling(conv.id);
|
||||
connectStomp(conv.id);
|
||||
router.replace(`/chat?id=${conv.id}`, { scroll: false });
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
@@ -356,7 +419,7 @@ function ChatPage() {
|
||||
}
|
||||
|
||||
async function switchConversation(convId) {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; }
|
||||
setMessages([]);
|
||||
setError(null);
|
||||
router.replace(`/chat?id=${convId}`, { scroll: false });
|
||||
@@ -426,7 +489,7 @@ function ChatPage() {
|
||||
<p style={s.sidebarEmpty}>No conversations yet.</p>
|
||||
)}
|
||||
<div style={{ overflowY: "auto", flex: 1 }}>
|
||||
{conversations.map((conv) => (
|
||||
{conversations.filter(c => c.status !== "CLOSED").map((conv) => (
|
||||
<button
|
||||
key={conv.id}
|
||||
style={{ ...s.convItem, ...(conv.id === conversation?.id ? s.convItemActive : {}) }}
|
||||
@@ -434,9 +497,7 @@ function ChatPage() {
|
||||
>
|
||||
<div style={s.convItemTop}>
|
||||
<span style={s.convItemSubject}>{conv.subject || `Conversation #${conv.id}`}</span>
|
||||
<span style={{ ...s.convStatusBadge, ...(conv.status === "OPEN" ? s.convStatusOpen : s.convStatusClosed) }}>
|
||||
{conv.status}
|
||||
</span>
|
||||
<span style={{ ...s.convStatusBadge, ...s.convStatusOpen }}>{conv.status}</span>
|
||||
</div>
|
||||
<div style={s.convItemBottom}>
|
||||
<span style={s.convItemMode}>{conv.mode === "HUMAN" ? "👤 Live" : "🤖 AI"}</span>
|
||||
@@ -445,6 +506,30 @@ function ChatPage() {
|
||||
</button>
|
||||
))}
|
||||
</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()}>
|
||||
+ New Conversation
|
||||
</button>
|
||||
@@ -488,13 +573,15 @@ function ChatPage() {
|
||||
Close Chat
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
style={s.aiBtn}
|
||||
onClick={() => router.push("/ai-chat")}
|
||||
title="Back to AI Assistant"
|
||||
>
|
||||
AI Assistant
|
||||
</button>
|
||||
{!isHuman && (
|
||||
<button
|
||||
style={s.aiBtn}
|
||||
onClick={() => router.push("/ai-chat")}
|
||||
title="Back to AI Assistant"
|
||||
>
|
||||
AI Assistant
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -540,16 +627,7 @@ function ChatPage() {
|
||||
</span>
|
||||
))}
|
||||
{msg.attachmentUrl && (
|
||||
<div style={s.attachment}>
|
||||
<a
|
||||
href={msg.attachmentUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={s.attachmentLink}
|
||||
>
|
||||
📎 {msg.attachmentName || "Attachment"}
|
||||
</a>
|
||||
</div>
|
||||
<AttachmentPreview url={msg.attachmentUrl} name={msg.attachmentName} token={token} />
|
||||
)}
|
||||
<div style={{ ...s.timestamp, ...(isOwn ? s.timestampUser : {}) }}>
|
||||
{msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : ""}
|
||||
@@ -563,6 +641,11 @@ function ChatPage() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{switchingConv && (
|
||||
<div style={{ textAlign: "center", padding: "1rem", color: "#aaa", fontSize: "0.85rem" }}>
|
||||
Loading messages…
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
@@ -1102,6 +1185,21 @@ const s = {
|
||||
},
|
||||
convStatusOpen: { background: "#e6f9ee", color: "#1a7a3c" },
|
||||
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: {
|
||||
margin: "0.65rem 1rem",
|
||||
background: "#333",
|
||||
|
||||
@@ -38,6 +38,10 @@ export default function ContactPage() {
|
||||
|
||||
async function handleSend(e) {
|
||||
e.preventDefault();
|
||||
if (!token) {
|
||||
setSendError("Please log in to send a message.");
|
||||
return;
|
||||
}
|
||||
setSending(true);
|
||||
setSendError(null);
|
||||
try {
|
||||
@@ -50,7 +54,7 @@ export default function ContactPage() {
|
||||
setSendSuccess(true);
|
||||
setSubject("");
|
||||
setBody("");
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setSendError("Failed to send message. Please try again.");
|
||||
} finally {
|
||||
setSending(false);
|
||||
@@ -61,21 +65,19 @@ export default function ContactPage() {
|
||||
<main className="info-page">
|
||||
<section className="info-hero">
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section className="info-content">
|
||||
<section className="contact-layout">
|
||||
<div className="info-card">
|
||||
<h2>General Contact</h2>
|
||||
<h2>Get in Touch</h2>
|
||||
<p>Email: hello@leonspetstore.com.au</p>
|
||||
<p>Phone: (03) 9000 0000</p>
|
||||
<p>Hours: Mon–Sat, 9:00 AM – 6:00 PM</p>
|
||||
</div>
|
||||
|
||||
{token && (
|
||||
<div className="info-card">
|
||||
<h2>Send Us a Message</h2>
|
||||
<div className="contact-form-section">
|
||||
<h3>Send Us a Message</h3>
|
||||
{sendSuccess ? (
|
||||
<p className="contact-success">Your message has been sent. We'll be in touch soon.</p>
|
||||
) : (
|
||||
@@ -100,7 +102,7 @@ export default function ContactPage() {
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
required
|
||||
maxLength={2000}
|
||||
rows={6}
|
||||
rows={5}
|
||||
/>
|
||||
</label>
|
||||
{sendError && <p className="contact-error">{sendError}</p>}
|
||||
@@ -110,18 +112,14 @@ export default function ContactPage() {
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="info-card">
|
||||
<h2>Store Locations</h2>
|
||||
|
||||
{loading && <p>Loading locations...</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 && (
|
||||
<div className="info-card-grid">
|
||||
|
||||
@@ -154,7 +154,7 @@ body {
|
||||
|
||||
.image-links-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 2rem;
|
||||
justify-content: center;
|
||||
align-items: stretch;
|
||||
@@ -685,33 +685,39 @@ body {
|
||||
}
|
||||
|
||||
.info-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(to bottom, #f9f9f9, #ffffff);
|
||||
}
|
||||
|
||||
.info-hero {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem 3rem;
|
||||
padding: 2.5rem 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.info-title {
|
||||
font-size: 3rem;
|
||||
font-size: 1.6rem;
|
||||
color: #333;
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.info-subtitle {
|
||||
font-size: 1.25rem;
|
||||
color: #666;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1rem;
|
||||
color: #888;
|
||||
margin-bottom: 1rem;
|
||||
max-width: 520px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.info-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem 4rem;
|
||||
padding: 0 2rem 1.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
@@ -733,6 +739,7 @@ body {
|
||||
padding-left: 1.2rem;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.info-card-grid {
|
||||
@@ -886,11 +893,15 @@ body {
|
||||
.slideshow-container {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
|
||||
.image-links-container {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.info-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.main-title {
|
||||
font-size: 2rem;
|
||||
@@ -1028,8 +1039,7 @@ body {
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
justify-self: end;
|
||||
padding-left: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nav-greeting {
|
||||
@@ -1706,6 +1716,49 @@ body {
|
||||
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 */
|
||||
|
||||
.appt-adopt-grid {
|
||||
@@ -2024,6 +2077,68 @@ body {
|
||||
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 */
|
||||
|
||||
.nav-store-select {
|
||||
@@ -2037,6 +2152,7 @@ body {
|
||||
margin-right: 0.5rem;
|
||||
outline: none;
|
||||
transition: background 0.2s ease;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.nav-store-select option {
|
||||
@@ -2639,6 +2755,25 @@ body {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2827,7 +2962,10 @@ body {
|
||||
|
||||
.nav-auth {
|
||||
gap: 0.35rem;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-greeting {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2893,7 +3031,8 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
grid-column: 3;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.nav-links,
|
||||
@@ -3022,13 +3161,20 @@ body {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.adopt-search-form {
|
||||
.adopt-controls-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.adopt-search-form {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.adopt-search-input {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.adopt-search-btn,
|
||||
@@ -3094,6 +3240,10 @@ img, video, iframe {
|
||||
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-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; }
|
||||
@@ -3103,7 +3253,43 @@ img, video, iframe {
|
||||
.contact-submit-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.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; }
|
||||
.pagination-controls { display: flex; align-items: center; justify-content: center; gap: 1rem; padding: 1.5rem 1rem; }
|
||||
.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:disabled { background: #ccc; cursor: not-allowed; }
|
||||
.pagination-controls { display: flex; align-items: center; justify-content: center; gap: 0.4rem; padding: 1.5rem 1rem; flex-wrap: wrap; }
|
||||
.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: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; }
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,8 +79,8 @@ export default function Home() {
|
||||
{/* About Us */}
|
||||
<section className="info-page">
|
||||
<div className="info-hero">
|
||||
<h2 className="info-title">About Leon's Pet Store</h2>
|
||||
<p className="info-subtitle">Your trusted local destination for pet care, adoption, and supplies — built on a love for animals and community.</p>
|
||||
<h2 className="info-title">About Us</h2>
|
||||
<p className="info-subtitle">A full-service pet store built on a love for animals and community.</p>
|
||||
<div className="title-decoration"></div>
|
||||
</div>
|
||||
<div className="info-content">
|
||||
|
||||
@@ -25,6 +25,8 @@ export default function ProfilePage() {
|
||||
|
||||
const [pets, setPets] = useState([]);
|
||||
const [loadingPets, setLoadingPets] = useState(false);
|
||||
const [orders, setOrders] = useState([]);
|
||||
const [loadingOrders, setLoadingOrders] = useState(false);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingPet, setEditingPet] = useState(null);
|
||||
const [petName, setPetName] = useState("");
|
||||
@@ -120,11 +122,27 @@ export default function ProfilePage() {
|
||||
};
|
||||
}, [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(() => {
|
||||
if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
|
||||
loadPets();
|
||||
loadOrders();
|
||||
}
|
||||
}, [user, loadPets]);
|
||||
}, [user, loadPets, loadOrders]);
|
||||
|
||||
useEffect(() => {
|
||||
let objectUrl = null;
|
||||
@@ -642,6 +660,46 @@ export default function ProfilePage() {
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useRef, useCallback, useEffect } from "react";
|
||||
import { createStompClient } from "@/lib/chatSocket";
|
||||
|
||||
const ChatWidgetContext = createContext(null);
|
||||
const API_BASE = "";
|
||||
@@ -60,24 +61,30 @@ export function ChatWidgetProvider({ children }) {
|
||||
const [liveSending, setLiveSending] = useState(false);
|
||||
const [switchingToHuman, setSwitchingToHuman] = useState(false);
|
||||
|
||||
const pollRef = useRef(null);
|
||||
const stompRef = useRef(null);
|
||||
const activeConvIdRef = useRef(null);
|
||||
const tokenRef = useRef(null); // FIX: store token so polling can restart
|
||||
const tokenRef = useRef(null);
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
|
||||
const disconnectStomp = useCallback(() => {
|
||||
if (stompRef.current) {
|
||||
stompRef.current.deactivate();
|
||||
stompRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchLiveMessages = useCallback(async (convId, token) => {
|
||||
if (!convId || !token) return;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data)) setLiveMessages(data);
|
||||
} catch { /* silent */ }
|
||||
const subscribeToConversation = useCallback((client, convId) => {
|
||||
client.subscribe(`/topic/chat/conversations/${convId}`, (frame) => {
|
||||
try {
|
||||
const msg = JSON.parse(frame.body);
|
||||
setLiveMessages((prev) => prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]);
|
||||
} catch { /* silent */ }
|
||||
});
|
||||
client.subscribe(`/user/queue/chat/conversations`, (frame) => {
|
||||
try {
|
||||
const conv = JSON.parse(frame.body);
|
||||
if (conv.id === convId) setActiveConv(conv);
|
||||
} catch { /* silent */ }
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadConversations = useCallback(async (token) => {
|
||||
@@ -97,21 +104,35 @@ export function ChatWidgetProvider({ children }) {
|
||||
|
||||
const openLiveConversation = useCallback(async (convId, token) => {
|
||||
if (!convId || !token) return;
|
||||
stopPolling();
|
||||
tokenRef.current = token; // FIX: save token for polling restart
|
||||
disconnectStomp();
|
||||
tokenRef.current = token;
|
||||
setActiveConvId(convId);
|
||||
activeConvIdRef.current = convId;
|
||||
setLiveMessages([]);
|
||||
setView("live");
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) setActiveConv(await res.json());
|
||||
} catch { /* silent */ }
|
||||
await fetchLiveMessages(convId, token);
|
||||
pollRef.current = setInterval(() => fetchLiveMessages(convId, token), 2500);
|
||||
}, [stopPolling, fetchLiveMessages]);
|
||||
|
||||
try {
|
||||
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) => {
|
||||
if (!text.trim() || liveSending || !token || !convId) return;
|
||||
@@ -122,11 +143,14 @@ export function ChatWidgetProvider({ children }) {
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||
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 {
|
||||
setLiveSending(false);
|
||||
}
|
||||
}, [liveSending, fetchLiveMessages]);
|
||||
}, [liveSending]);
|
||||
|
||||
const startLiveChat = useCallback(async (token) => {
|
||||
if (!token || switchingToHuman) return;
|
||||
@@ -149,19 +173,22 @@ export function ChatWidgetProvider({ children }) {
|
||||
}
|
||||
}, [switchingToHuman, openLiveConversation]);
|
||||
|
||||
// FIX: Single effect that handles both stopping AND restarting polling
|
||||
useEffect(() => {
|
||||
if (!isOpen || view !== "live") {
|
||||
stopPolling();
|
||||
} else if (isOpen && view === "live" && activeConvIdRef.current && tokenRef.current) {
|
||||
stopPolling();
|
||||
fetchLiveMessages(activeConvIdRef.current, tokenRef.current);
|
||||
pollRef.current = setInterval(
|
||||
() => fetchLiveMessages(activeConvIdRef.current, tokenRef.current),
|
||||
2500
|
||||
);
|
||||
disconnectStomp();
|
||||
} else if (isOpen && view === "live" && activeConvIdRef.current && tokenRef.current && !stompRef.current) {
|
||||
const convId = activeConvIdRef.current;
|
||||
const token = tokenRef.current;
|
||||
const client = createStompClient(token);
|
||||
client.onConnect = () => subscribeToConversation(client, convId);
|
||||
stompRef.current = client;
|
||||
client.activate();
|
||||
}
|
||||
}, [isOpen, view, stopPolling, fetchLiveMessages]);
|
||||
}, [isOpen, view, disconnectStomp, subscribeToConversation]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => disconnectStomp();
|
||||
}, [disconnectStomp]);
|
||||
|
||||
const toggleOpen = useCallback(() => setIsOpen((o) => !o), []);
|
||||
const openView = useCallback((v) => setView(v), []);
|
||||
@@ -185,4 +212,4 @@ export function useChatWidget() {
|
||||
const ctx = useContext(ChatWidgetContext);
|
||||
if (!ctx) throw new Error("useChatWidget must be used within ChatWidgetProvider");
|
||||
return ctx;
|
||||
}
|
||||
}
|
||||
|
||||
16
web/lib/chatSocket.js
Normal file
16
web/lib/chatSocket.js
Normal 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
137
web/package-lock.json
generated
@@ -8,11 +8,13 @@
|
||||
"name": "threaded-pets",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@stomp/stompjs": "^7.3.0",
|
||||
"@stripe/react-stripe-js": "^3.1.1",
|
||||
"@stripe/stripe-js": "^5.5.0",
|
||||
"next": "^16.2.2",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
"react-dom": "19.2.3",
|
||||
"sockjs-client": "^1.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
@@ -1232,6 +1234,12 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "3.10.0",
|
||||
"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_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": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -3535,6 +3552,18 @@
|
||||
"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": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
@@ -3920,6 +3949,12 @@
|
||||
"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": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -3957,6 +3992,12 @@
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||
@@ -4932,7 +4973,6 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
@@ -5402,6 +5442,12 @@
|
||||
"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": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -5494,6 +5540,12 @@
|
||||
"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": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@@ -5590,6 +5642,26 @@
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -6414,6 +6514,39 @@
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -9,11 +9,13 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@stomp/stompjs": "^7.3.0",
|
||||
"@stripe/react-stripe-js": "^3.1.1",
|
||||
"@stripe/stripe-js": "^5.5.0",
|
||||
"next": "^16.2.2",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
"react-dom": "19.2.3",
|
||||
"sockjs-client": "^1.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
||||
Reference in New Issue
Block a user