merge main into branch

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

View File

@@ -9,21 +9,53 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
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 }}

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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"]

View File

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

View File

@@ -38,6 +38,16 @@
<artifactId>spring-boot-starter-security</artifactId>
</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>

View File

@@ -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) {

View File

@@ -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;
}
}

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ import com.petshop.backend.entity.StoreLocation;
import com.petshop.backend.entity.User;
import com.petshop.backend.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));
}

View File

@@ -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(

View File

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

View File

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

View File

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

View File

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

View File

@@ -41,11 +41,11 @@ public interface PetRepository extends JpaRepository<Pet, Long> {
Optional<Pet> findByIdAndOwner_Id(Long id, Long ownerId);
@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 " +

View File

@@ -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,

View File

@@ -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;
}

View File

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

View File

@@ -1,38 +1,65 @@
package com.petshop.backend.service;
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;
}
}

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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";

View File

@@ -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();

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import com.petshop.backend.entity.User;
import com.petshop.backend.repository.StoreRepository;
import com.petshop.backend.repository.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

View File

@@ -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
View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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>

View File

@@ -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">

View File

@@ -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",

View File

@@ -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: MonSat, 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&apos;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">

View File

@@ -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;
}
}

View File

@@ -79,8 +79,8 @@ export default function Home() {
{/* About Us */}
<section className="info-page">
<div className="info-hero">
<h2 className="info-title">About Leon&apos;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">

View File

@@ -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>
);
}

View File

@@ -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
View File

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

137
web/package-lock.json generated
View File

@@ -8,11 +8,13 @@
"name": "threaded-pets",
"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",

View File

@@ -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",