diff --git a/src/main/java/iuh/fit/se/ecommerce/config/CacheConfig.java b/src/main/java/iuh/fit/se/ecommerce/config/CacheConfig.java new file mode 100644 index 0000000..c56eac0 --- /dev/null +++ b/src/main/java/iuh/fit/se/ecommerce/config/CacheConfig.java @@ -0,0 +1,20 @@ +package iuh.fit.se.ecommerce.config; + +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableCaching +public class CacheConfig { + + @Bean + public CacheManager cacheManager() { + // Dùng in-memory cache (ConcurrentMap) + // Nếu có Redis, thay bằng RedisCacheManager + return new ConcurrentMapCacheManager("nominatim"); + } +} + diff --git a/src/main/java/iuh/fit/se/ecommerce/config/NominatimCacheKeyGenerator.java b/src/main/java/iuh/fit/se/ecommerce/config/NominatimCacheKeyGenerator.java new file mode 100644 index 0000000..f79889c --- /dev/null +++ b/src/main/java/iuh/fit/se/ecommerce/config/NominatimCacheKeyGenerator.java @@ -0,0 +1,24 @@ +package iuh.fit.se.ecommerce.config; + +import org.springframework.cache.interceptor.KeyGenerator; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Method; +import java.math.BigDecimal; +import java.math.RoundingMode; + +@Component("nominatimKeyGenerator") +public class NominatimCacheKeyGenerator implements KeyGenerator { + @Override + public Object generate(Object target, Method method, Object... params) { + BigDecimal lat = (BigDecimal) params[0]; + BigDecimal lng = (BigDecimal) params[1]; + + // Round to 4 decimals (≈11m accuracy) for cache key + BigDecimal roundedLat = lat.setScale(4, RoundingMode.HALF_UP); + BigDecimal roundedLng = lng.setScale(4, RoundingMode.HALF_UP); + + return roundedLat.toString() + ":" + roundedLng.toString(); + } +} + diff --git a/src/main/java/iuh/fit/se/ecommerce/config/SecurityConfig.java b/src/main/java/iuh/fit/se/ecommerce/config/SecurityConfig.java index 05fbdd7..6ba246e 100644 --- a/src/main/java/iuh/fit/se/ecommerce/config/SecurityConfig.java +++ b/src/main/java/iuh/fit/se/ecommerce/config/SecurityConfig.java @@ -63,6 +63,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/profile.html", "/promotions.html", "/products.html", + "/search-results.html", "/forgot-password.html", "/payment-success.html", "/payment-cancel.html", @@ -97,6 +98,12 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers(HttpMethod.POST, "/api/payments/create").authenticated() .requestMatchers(HttpMethod.GET, "/api/payments/status/**").authenticated() + // Address endpoints (authenticated) + .requestMatchers("/api/addresses/**").authenticated() + + // Geocoding endpoints (authenticated) + .requestMatchers(HttpMethod.GET, "/api/geocoding/**").authenticated() + // Admin endpoints (require roles for page access) .requestMatchers(HttpMethod.GET, "/admin/**").hasAnyRole("ADMIN", "EDITOR") .requestMatchers(HttpMethod.POST, "/admin/**").hasAnyRole("ADMIN", "EDITOR") diff --git a/src/main/java/iuh/fit/se/ecommerce/controller/AddressController.java b/src/main/java/iuh/fit/se/ecommerce/controller/AddressController.java new file mode 100644 index 0000000..0717593 --- /dev/null +++ b/src/main/java/iuh/fit/se/ecommerce/controller/AddressController.java @@ -0,0 +1,70 @@ +package iuh.fit.se.ecommerce.controller; + +import iuh.fit.se.ecommerce.dto.request.AddressRequest; +import iuh.fit.se.ecommerce.dto.response.AddressResponse; +import iuh.fit.se.ecommerce.service.interfaces.AddressService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/addresses") +@RequiredArgsConstructor +public class AddressController { + + private final AddressService addressService; + + @GetMapping + public ResponseEntity> getUserAddresses( + @AuthenticationPrincipal UserDetails userDetails) { + List addresses = addressService.getUserAddresses(userDetails.getUsername()); + return ResponseEntity.ok(addresses); + } + + @GetMapping("/{id}") + public ResponseEntity getAddress( + @PathVariable Long id, + @AuthenticationPrincipal UserDetails userDetails) { + AddressResponse address = addressService.getAddressById(id, userDetails.getUsername()); + return ResponseEntity.ok(address); + } + + @PostMapping + public ResponseEntity createAddress( + @AuthenticationPrincipal UserDetails userDetails, + @Valid @RequestBody AddressRequest request) { + AddressResponse address = addressService.createAddress(request, userDetails.getUsername()); + return ResponseEntity.ok(address); + } + + @PutMapping("/{id}") + public ResponseEntity updateAddress( + @PathVariable Long id, + @AuthenticationPrincipal UserDetails userDetails, + @Valid @RequestBody AddressRequest request) { + AddressResponse address = addressService.updateAddress(id, request, userDetails.getUsername()); + return ResponseEntity.ok(address); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteAddress( + @PathVariable Long id, + @AuthenticationPrincipal UserDetails userDetails) { + addressService.deleteAddress(id, userDetails.getUsername()); + return ResponseEntity.noContent().build(); + } + + @PutMapping("/{id}/set-default") + public ResponseEntity setDefaultAddress( + @PathVariable Long id, + @AuthenticationPrincipal UserDetails userDetails) { + AddressResponse address = addressService.setDefaultAddress(id, userDetails.getUsername()); + return ResponseEntity.ok(address); + } +} + diff --git a/src/main/java/iuh/fit/se/ecommerce/controller/GeocodingController.java b/src/main/java/iuh/fit/se/ecommerce/controller/GeocodingController.java new file mode 100644 index 0000000..d044046 --- /dev/null +++ b/src/main/java/iuh/fit/se/ecommerce/controller/GeocodingController.java @@ -0,0 +1,27 @@ +package iuh.fit.se.ecommerce.controller; + +import iuh.fit.se.ecommerce.dto.response.AddressGeocodeResponse; +import iuh.fit.se.ecommerce.service.interfaces.NominatimService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.math.BigDecimal; + +@RestController +@RequestMapping("/api/geocoding") +@RequiredArgsConstructor +public class GeocodingController { + + private final NominatimService nominatimService; + + @GetMapping("/reverse") + public ResponseEntity reverseGeocode( + @RequestParam BigDecimal lat, + @RequestParam BigDecimal lng) { + + AddressGeocodeResponse result = nominatimService.reverseGeocode(lat, lng); + return ResponseEntity.ok(result); + } +} + diff --git a/src/main/java/iuh/fit/se/ecommerce/controller/ProductController.java b/src/main/java/iuh/fit/se/ecommerce/controller/ProductController.java index 344df9b..a1bd9f2 100644 --- a/src/main/java/iuh/fit/se/ecommerce/controller/ProductController.java +++ b/src/main/java/iuh/fit/se/ecommerce/controller/ProductController.java @@ -5,6 +5,7 @@ import iuh.fit.se.ecommerce.dto.response.ProductResponse; import iuh.fit.se.ecommerce.service.interfaces.ProductService; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -54,4 +55,20 @@ public ResponseEntity> getHotSaleProducts( @RequestParam(required = false, defaultValue = "6") int limit) { return ResponseEntity.ok(productService.getHotSaleProducts(limit)); } + + @GetMapping("/search/autocomplete") + public ResponseEntity> searchAutocomplete( + @RequestParam String q, + @RequestParam(required = false, defaultValue = "5") int limit) { + return ResponseEntity.ok(productService.searchAutocomplete(q, limit)); + } + + @GetMapping("/search") + public ResponseEntity> searchProducts( + @RequestParam String q, + @RequestParam(required = false, defaultValue = "0") int page, + @RequestParam(required = false, defaultValue = "20") int size, + @RequestParam(required = false, defaultValue = "default") String sort) { + return ResponseEntity.ok(productService.searchProducts(q, page, size, sort)); + } } diff --git a/src/main/java/iuh/fit/se/ecommerce/controller/WebController.java b/src/main/java/iuh/fit/se/ecommerce/controller/WebController.java index 4f974a0..dec1be5 100644 --- a/src/main/java/iuh/fit/se/ecommerce/controller/WebController.java +++ b/src/main/java/iuh/fit/se/ecommerce/controller/WebController.java @@ -46,6 +46,11 @@ public String products() { return "products"; } + @GetMapping("/search-results.html") + public String searchResults() { + return "search-results"; + } + @GetMapping("/oauth2/callback") public String oauth2Callback() { return "auth/oauth2-callback"; diff --git a/src/main/java/iuh/fit/se/ecommerce/dto/request/AddressRequest.java b/src/main/java/iuh/fit/se/ecommerce/dto/request/AddressRequest.java new file mode 100644 index 0000000..adb499b --- /dev/null +++ b/src/main/java/iuh/fit/se/ecommerce/dto/request/AddressRequest.java @@ -0,0 +1,37 @@ +package iuh.fit.se.ecommerce.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AddressRequest { + private String label; // Nhãn địa chỉ (tùy chọn): "Nhà riêng", "Công ty", etc. + + @NotBlank(message = "Tên người nhận không được để trống") + private String receiverName; + + @NotBlank(message = "Số điện thoại không được để trống") + private String receiverPhone; + + @NotBlank(message = "Tỉnh/TP không được để trống") + private String province; + + @NotBlank(message = "Phường/Xã không được để trống") + private String ward; + + @NotBlank(message = "Địa chỉ chi tiết không được để trống") + private String detail; + + private Boolean isDefault = false; + + // Tọa độ (optional - có thể null nếu user nhập tay không dùng map) + private Double latitude; // Vĩ độ + private Double longitude; // Kinh độ +} + diff --git a/src/main/java/iuh/fit/se/ecommerce/dto/response/AddressGeocodeResponse.java b/src/main/java/iuh/fit/se/ecommerce/dto/response/AddressGeocodeResponse.java new file mode 100644 index 0000000..23a2475 --- /dev/null +++ b/src/main/java/iuh/fit/se/ecommerce/dto/response/AddressGeocodeResponse.java @@ -0,0 +1,24 @@ +package iuh.fit.se.ecommerce.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AddressGeocodeResponse { + private String houseNumber; + private String road; + private String ward; + private String district; + private String province; + private String country; + private String countryCode; + private String postcode; + private String displayName; + private String fullAddress; +} + diff --git a/src/main/java/iuh/fit/se/ecommerce/dto/response/AddressResponse.java b/src/main/java/iuh/fit/se/ecommerce/dto/response/AddressResponse.java index 4634d4c..22c1246 100644 --- a/src/main/java/iuh/fit/se/ecommerce/dto/response/AddressResponse.java +++ b/src/main/java/iuh/fit/se/ecommerce/dto/response/AddressResponse.java @@ -11,14 +11,16 @@ @AllArgsConstructor public class AddressResponse { private Long id; + private String label; private String receiverName; private String receiverPhone; private String receiverEmail; private String country; private String province; - private String district; private String ward; private String addressDetail; private boolean isDefault; + private Double latitude; // Vĩ độ (có thể null) + private Double longitude; // Kinh độ (có thể null) } diff --git a/src/main/java/iuh/fit/se/ecommerce/entity/Address.java b/src/main/java/iuh/fit/se/ecommerce/entity/Address.java index cd05213..a9150da 100644 --- a/src/main/java/iuh/fit/se/ecommerce/entity/Address.java +++ b/src/main/java/iuh/fit/se/ecommerce/entity/Address.java @@ -3,6 +3,10 @@ import com.fasterxml.jackson.annotation.JsonBackReference; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; @Entity @Table(name = "addresses") @@ -20,14 +24,25 @@ public class Address { private String receiverName; private String receiverPhone; private String province; - private String district; private String ward; private String detail; private boolean isDefault = false; + @Column(nullable = true) + private Double latitude; // Vĩ độ (optional - có thể null nếu user nhập tay) + + @Column(nullable = true) + private Double longitude; // Kinh độ (optional - có thể null nếu user nhập tay) + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") @JsonBackReference private User user; + + @CreationTimestamp + private LocalDateTime createdAt; + + @UpdateTimestamp + private LocalDateTime updatedAt; } diff --git a/src/main/java/iuh/fit/se/ecommerce/repository/AddressRepository.java b/src/main/java/iuh/fit/se/ecommerce/repository/AddressRepository.java index 4b7b84d..d295663 100644 --- a/src/main/java/iuh/fit/se/ecommerce/repository/AddressRepository.java +++ b/src/main/java/iuh/fit/se/ecommerce/repository/AddressRepository.java @@ -1,6 +1,16 @@ package iuh.fit.se.ecommerce.repository; import iuh.fit.se.ecommerce.entity.Address; +import iuh.fit.se.ecommerce.entity.User; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; -public interface AddressRepository extends JpaRepository {} \ No newline at end of file +import java.util.List; +import java.util.Optional; + +@Repository +public interface AddressRepository extends JpaRepository { + List
findByUserOrderByIsDefaultDescCreatedAtDesc(User user); + Optional
findByIdAndUser(Long id, User user); + Optional
findByUserAndIsDefaultTrue(User user); +} \ No newline at end of file diff --git a/src/main/java/iuh/fit/se/ecommerce/repository/ProductRepositoryCustom.java b/src/main/java/iuh/fit/se/ecommerce/repository/ProductRepositoryCustom.java index c99da18..97f251a 100644 --- a/src/main/java/iuh/fit/se/ecommerce/repository/ProductRepositoryCustom.java +++ b/src/main/java/iuh/fit/se/ecommerce/repository/ProductRepositoryCustom.java @@ -7,5 +7,6 @@ public interface ProductRepositoryCustom { Page search(ProductSearchCriteria criteria, Pageable pageable); + Page search(ProductSearchCriteria criteria, Pageable pageable, String sort); } diff --git a/src/main/java/iuh/fit/se/ecommerce/repository/ProductRepositoryImpl.java b/src/main/java/iuh/fit/se/ecommerce/repository/ProductRepositoryImpl.java index 5191dbc..eac431d 100644 --- a/src/main/java/iuh/fit/se/ecommerce/repository/ProductRepositoryImpl.java +++ b/src/main/java/iuh/fit/se/ecommerce/repository/ProductRepositoryImpl.java @@ -144,4 +144,162 @@ public Page search(ProductSearchCriteria criteria, Pageable pageable) { return new PageImpl<>(content, pageable, total); } + + @Override + @Transactional(readOnly = true) + public Page search(ProductSearchCriteria criteria, Pageable pageable, String sort) { + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(Product.class); + Root root = cq.from(Product.class); + Join specJoin = root.join("specifications", JoinType.LEFT); + cq.select(root).distinct(true); + + List predicates = buildPredicates(criteria, cb, root, specJoin); + + if (!predicates.isEmpty()) { + cq.where(cb.and(predicates.toArray(new Predicate[0]))); + } + + // Apply sorting + applySorting(cq, cb, root, sort); + + TypedQuery query = em.createQuery(cq); + query.setFirstResult((int) pageable.getOffset()); + query.setMaxResults(pageable.getPageSize()); + List content = query.getResultList(); + + // Count query + CriteriaQuery countCq = cb.createQuery(Long.class); + Root countRoot = countCq.from(Product.class); + countRoot.join("specifications", JoinType.LEFT); + countCq.select(cb.countDistinct(countRoot)); + + if (!predicates.isEmpty()) { + List countPreds = buildCountPredicates(criteria, cb, countRoot); + countCq.where(cb.and(countPreds.toArray(new Predicate[0]))); + } + + Long total = em.createQuery(countCq).getSingleResult(); + + return new PageImpl<>(content, pageable, total); + } + + private List buildPredicates(ProductSearchCriteria criteria, CriteriaBuilder cb, + Root root, Join specJoin) { + List predicates = new ArrayList<>(); + + if (criteria.getBrand() != null && !criteria.getBrand().isBlank()) { + predicates.add(cb.like(cb.lower(root.get("brand")), "%" + criteria.getBrand().toLowerCase() + "%")); + } + + if (criteria.getProductType() != null && !criteria.getProductType().isBlank()) { + try { + ProductType pt = ProductType.valueOf(criteria.getProductType().toUpperCase()); + predicates.add(cb.equal(root.get("productType"), pt)); + } catch (IllegalArgumentException ignored) { + } + } + + if (criteria.getMinPrice() != null) { + predicates.add(cb.greaterThanOrEqualTo(root.get("price"), criteria.getMinPrice())); + } + if (criteria.getMaxPrice() != null) { + predicates.add(cb.lessThanOrEqualTo(root.get("price"), criteria.getMaxPrice())); + } + + if (criteria.getSpecTerms() != null && !criteria.getSpecTerms().isEmpty()) { + List specPreds = new ArrayList<>(); + for (String t : criteria.getSpecTerms()) { + String like = "%" + t.toLowerCase() + "%"; + specPreds.add(cb.or( + cb.like(cb.lower(specJoin.get("specName")), like), + cb.like(cb.lower(specJoin.get("specValue")), like) + )); + } + predicates.add(cb.or(specPreds.toArray(new Predicate[0]))); + } + + if (criteria.getText() != null && !criteria.getText().isBlank()) { + String like = "%" + criteria.getText().toLowerCase() + "%"; + Join specJoin2 = root.join("specifications", JoinType.LEFT); + predicates.add(cb.or( + cb.like(cb.lower(root.get("name")), like), + cb.like(cb.lower(root.get("description")), like), + cb.like(cb.lower(root.get("brand")), like), + cb.like(cb.lower(specJoin2.get("specName")), like), + cb.like(cb.lower(specJoin2.get("specValue")), like) + )); + } + + return predicates; + } + + private List buildCountPredicates(ProductSearchCriteria criteria, CriteriaBuilder cb, + Root countRoot) { + List countPreds = new ArrayList<>(); + + if (criteria.getBrand() != null && !criteria.getBrand().isBlank()) { + countPreds.add(cb.like(cb.lower(countRoot.get("brand")), "%" + criteria.getBrand().toLowerCase() + "%")); + } + if (criteria.getProductType() != null && !criteria.getProductType().isBlank()) { + try { + ProductType pt = ProductType.valueOf(criteria.getProductType().toUpperCase()); + countPreds.add(cb.equal(countRoot.get("productType"), pt)); + } catch (IllegalArgumentException ignored) { + } + } + if (criteria.getMinPrice() != null) { + countPreds.add(cb.greaterThanOrEqualTo(countRoot.get("price"), criteria.getMinPrice())); + } + if (criteria.getMaxPrice() != null) { + countPreds.add(cb.lessThanOrEqualTo(countRoot.get("price"), criteria.getMaxPrice())); + } + + if (criteria.getSpecTerms() != null && !criteria.getSpecTerms().isEmpty()) { + Join specJoinCount = countRoot.join("specifications", JoinType.LEFT); + List specPreds = new ArrayList<>(); + for (String t : criteria.getSpecTerms()) { + String like = "%" + t.toLowerCase() + "%"; + specPreds.add(cb.or( + cb.like(cb.lower(specJoinCount.get("specName")), like), + cb.like(cb.lower(specJoinCount.get("specValue")), like) + )); + } + countPreds.add(cb.or(specPreds.toArray(new Predicate[0]))); + } + + if (criteria.getText() != null && !criteria.getText().isBlank()) { + String like = "%" + criteria.getText().toLowerCase() + "%"; + Join specJoinCount2 = countRoot.join("specifications", JoinType.LEFT); + countPreds.add(cb.or( + cb.like(cb.lower(countRoot.get("name")), like), + cb.like(cb.lower(countRoot.get("description")), like), + cb.like(cb.lower(countRoot.get("brand")), like), + cb.like(cb.lower(specJoinCount2.get("specName")), like), + cb.like(cb.lower(specJoinCount2.get("specValue")), like) + )); + } + + return countPreds; + } + + private void applySorting(CriteriaQuery cq, CriteriaBuilder cb, Root root, String sort) { + switch (sort) { + case "price-asc": + cq.orderBy(cb.asc(root.get("price"))); + break; + case "price-desc": + cq.orderBy(cb.desc(root.get("price"))); + break; + case "name-asc": + cq.orderBy(cb.asc(root.get("name"))); + break; + case "name-desc": + cq.orderBy(cb.desc(root.get("name"))); + break; + default: + cq.orderBy(cb.desc(root.get("id"))); + break; + } + } } diff --git a/src/main/java/iuh/fit/se/ecommerce/service/impl/AddressServiceImpl.java b/src/main/java/iuh/fit/se/ecommerce/service/impl/AddressServiceImpl.java new file mode 100644 index 0000000..06fd5fe --- /dev/null +++ b/src/main/java/iuh/fit/se/ecommerce/service/impl/AddressServiceImpl.java @@ -0,0 +1,169 @@ +package iuh.fit.se.ecommerce.service.impl; + +import iuh.fit.se.ecommerce.dto.request.AddressRequest; +import iuh.fit.se.ecommerce.dto.response.AddressResponse; +import iuh.fit.se.ecommerce.entity.Address; +import iuh.fit.se.ecommerce.entity.User; +import iuh.fit.se.ecommerce.exception.AppException; +import iuh.fit.se.ecommerce.exception.ErrorCode; +import iuh.fit.se.ecommerce.repository.AddressRepository; +import iuh.fit.se.ecommerce.repository.UserRepository; +import iuh.fit.se.ecommerce.service.interfaces.AddressService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AddressServiceImpl implements AddressService { + + private final AddressRepository addressRepository; + private final UserRepository userRepository; + + @Override + public List getUserAddresses(String userEmail) { + User user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new AppException(ErrorCode.USER_NOT_FOUND)); + + List
addresses = addressRepository.findByUserOrderByIsDefaultDescCreatedAtDesc(user); + return addresses.stream() + .map(this::mapToResponse) + .collect(Collectors.toList()); + } + + @Override + public AddressResponse getAddressById(Long addressId, String userEmail) { + User user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new AppException(ErrorCode.USER_NOT_FOUND)); + + Address address = addressRepository.findByIdAndUser(addressId, user) + .orElseThrow(() -> new AppException(ErrorCode.BAD_REQUEST, "Địa chỉ không tồn tại")); + + return mapToResponse(address); + } + + @Override + @Transactional + public AddressResponse createAddress(AddressRequest request, String userEmail) { + User user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new AppException(ErrorCode.USER_NOT_FOUND)); + + // Nếu set default, unset các address default khác + if (Boolean.TRUE.equals(request.getIsDefault())) { + addressRepository.findByUserAndIsDefaultTrue(user) + .ifPresent(addr -> { + addr.setDefault(false); + addressRepository.save(addr); + }); + } + + Address address = Address.builder() + .label(request.getLabel()) + .receiverName(request.getReceiverName()) + .receiverPhone(request.getReceiverPhone()) + .province(request.getProvince()) + .ward(request.getWard()) + .detail(request.getDetail()) + .isDefault(Boolean.TRUE.equals(request.getIsDefault())) + .latitude(request.getLatitude()) // Có thể null + .longitude(request.getLongitude()) // Có thể null + .user(user) + .build(); + + address = addressRepository.save(address); + log.info("Created address {} for user {}", address.getId(), userEmail); + + return mapToResponse(address); + } + + @Override + @Transactional + public AddressResponse updateAddress(Long addressId, AddressRequest request, String userEmail) { + User user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new AppException(ErrorCode.USER_NOT_FOUND)); + + Address address = addressRepository.findByIdAndUser(addressId, user) + .orElseThrow(() -> new AppException(ErrorCode.BAD_REQUEST, "Địa chỉ không tồn tại")); + + // Nếu set default, unset các address default khác + if (Boolean.TRUE.equals(request.getIsDefault()) && !address.isDefault()) { + addressRepository.findByUserAndIsDefaultTrue(user) + .ifPresent(addr -> { + addr.setDefault(false); + addressRepository.save(addr); + }); + } + + address.setLabel(request.getLabel()); + address.setReceiverName(request.getReceiverName()); + address.setReceiverPhone(request.getReceiverPhone()); + address.setProvince(request.getProvince()); + address.setWard(request.getWard()); + address.setDetail(request.getDetail()); + address.setDefault(Boolean.TRUE.equals(request.getIsDefault())); + address.setLatitude(request.getLatitude()); // Có thể null + address.setLongitude(request.getLongitude()); // Có thể null + + address = addressRepository.save(address); + log.info("Updated address {} for user {}", addressId, userEmail); + + return mapToResponse(address); + } + + @Override + @Transactional + public void deleteAddress(Long addressId, String userEmail) { + User user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new AppException(ErrorCode.USER_NOT_FOUND)); + + Address address = addressRepository.findByIdAndUser(addressId, user) + .orElseThrow(() -> new AppException(ErrorCode.BAD_REQUEST, "Địa chỉ không tồn tại")); + + addressRepository.delete(address); + log.info("Deleted address {} for user {}", addressId, userEmail); + } + + @Override + @Transactional + public AddressResponse setDefaultAddress(Long addressId, String userEmail) { + User user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new AppException(ErrorCode.USER_NOT_FOUND)); + + Address address = addressRepository.findByIdAndUser(addressId, user) + .orElseThrow(() -> new AppException(ErrorCode.BAD_REQUEST, "Địa chỉ không tồn tại")); + + // Unset các address default khác + addressRepository.findByUserAndIsDefaultTrue(user) + .ifPresent(addr -> { + addr.setDefault(false); + addressRepository.save(addr); + }); + + address.setDefault(true); + address = addressRepository.save(address); + + return mapToResponse(address); + } + + private AddressResponse mapToResponse(Address address) { + return AddressResponse.builder() + .id(address.getId()) + .label(address.getLabel()) + .receiverName(address.getReceiverName()) + .receiverPhone(address.getReceiverPhone()) + .province(address.getProvince()) + .ward(address.getWard()) + .addressDetail(address.getDetail()) + .isDefault(address.isDefault()) + .latitude(address.getLatitude()) + .longitude(address.getLongitude()) + .country("Vietnam") + .build(); + } +} + diff --git a/src/main/java/iuh/fit/se/ecommerce/service/impl/NominatimServiceImpl.java b/src/main/java/iuh/fit/se/ecommerce/service/impl/NominatimServiceImpl.java new file mode 100644 index 0000000..8f5c8cc --- /dev/null +++ b/src/main/java/iuh/fit/se/ecommerce/service/impl/NominatimServiceImpl.java @@ -0,0 +1,178 @@ +package iuh.fit.se.ecommerce.service.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import iuh.fit.se.ecommerce.dto.response.AddressGeocodeResponse; +import iuh.fit.se.ecommerce.exception.AppException; +import iuh.fit.se.ecommerce.exception.ErrorCode; +import iuh.fit.se.ecommerce.service.interfaces.NominatimService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.net.URI; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NominatimServiceImpl implements NominatimService { + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + + @Value("${nominatim.api-url:https://nominatim.openstreetmap.org}") + private String nominatimUrl; + + @Value("${nominatim.email:baon6777@gmail.com}") + private String email; + + @Value("${nominatim.user-agent:SpringBoot-ECommerce/1.0}") + private String userAgent; + + // Rate limiting: max 1 request/second + private long lastRequestTime = 0; + private static final long MIN_REQUEST_INTERVAL = 1000; // 1 second + + @Override + @Cacheable(value = "nominatim", keyGenerator = "nominatimKeyGenerator") + public AddressGeocodeResponse reverseGeocode(BigDecimal lat, BigDecimal lng) { + // Rate limiting + long now = System.currentTimeMillis(); + long timeSinceLastRequest = now - lastRequestTime; + if (timeSinceLastRequest < MIN_REQUEST_INTERVAL) { + try { + Thread.sleep(MIN_REQUEST_INTERVAL - timeSinceLastRequest); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + lastRequestTime = System.currentTimeMillis(); + + // Round coordinates for cache key (4 decimals ≈ 11m accuracy) + BigDecimal roundedLat = lat.setScale(4, RoundingMode.HALF_UP); + BigDecimal roundedLng = lng.setScale(4, RoundingMode.HALF_UP); + + try { + // Build URL + String url = String.format( + "%s/reverse?format=jsonv2&lat=%s&lon=%s&addressdetails=1&accept-language=vi&email=%s", + nominatimUrl, + roundedLat, + roundedLng, + email + ); + + // Headers (BẮT BUỘC theo policy) + HttpHeaders headers = new HttpHeaders(); + headers.set("User-Agent", userAgent); + headers.set("Referer", "http://localhost:8080"); + + RequestEntity request = new RequestEntity<>( + headers, + HttpMethod.GET, + URI.create(url) + ); + + log.info("Calling Nominatim API: lat={}, lng={}", roundedLat, roundedLng); + + // Call API + ResponseEntity response = restTemplate.exchange( + request, + String.class + ); + + if (!response.getStatusCode().is2xxSuccessful()) { + throw new AppException(ErrorCode.INTERNAL_ERROR, + "Nominatim API error: " + response.getStatusCode()); + } + + // Parse response + JsonNode json = objectMapper.readTree(response.getBody()); + JsonNode addressNode = json.get("address"); + + if (addressNode == null) { + throw new AppException(ErrorCode.BAD_REQUEST, "Không tìm thấy địa chỉ cho tọa độ này"); + } + + // Map to response + return mapToResponse(json, addressNode); + + } catch (AppException e) { + throw e; + } catch (Exception e) { + log.error("Error calling Nominatim API: ", e); + throw new AppException(ErrorCode.INTERNAL_ERROR, + "Lỗi khi gọi Nominatim API: " + e.getMessage()); + } + } + + private AddressGeocodeResponse mapToResponse(JsonNode json, JsonNode addressNode) { + // Extract address components với fallback + String houseNumber = getText(addressNode, "house_number"); + String road = getText(addressNode, "road"); + String ward = getText(addressNode, "suburb", "neighbourhood", "village"); + String province = getText(addressNode, "city", "town", "state", "region"); + String country = getText(addressNode, "country", "Việt Nam"); + String countryCode = getText(addressNode, "country_code", "vn"); + String postcode = getText(addressNode, "postcode"); + String displayName = getText(json, "display_name"); + + // Build full address + StringBuilder fullAddress = new StringBuilder(); + if (houseNumber != null && !houseNumber.isEmpty()) { + fullAddress.append(houseNumber).append(" "); + } + if (road != null && !road.isEmpty()) { + fullAddress.append(road); + } + if (ward != null && !ward.isEmpty()) { + if (fullAddress.length() > 0) fullAddress.append(", "); + fullAddress.append(ward); + } + if (province != null && !province.isEmpty()) { + if (fullAddress.length() > 0) fullAddress.append(", "); + fullAddress.append(province); + } + if (country != null && !country.isEmpty()) { + if (fullAddress.length() > 0) fullAddress.append(", "); + fullAddress.append(country); + } + + return AddressGeocodeResponse.builder() + .houseNumber(houseNumber) + .road(road) + .ward(ward) + .district(null) // Không còn dùng district + .province(province) + .country(country) + .countryCode(countryCode) + .postcode(postcode) + .displayName(displayName) + .fullAddress(fullAddress.toString().trim()) + .build(); + } + + private String getText(JsonNode node, String... keys) { + for (String key : keys) { + JsonNode value = node.get(key); + if (value != null && !value.isNull() && !value.asText().isEmpty()) { + return value.asText(); + } + } + // Last key là fallback value + if (keys.length > 0 && (keys[keys.length - 1].equals("Việt Nam") || keys[keys.length - 1].equals("vn"))) { + return keys[keys.length - 1]; + } + return null; + } +} + diff --git a/src/main/java/iuh/fit/se/ecommerce/service/impl/OrderServiceImpl.java b/src/main/java/iuh/fit/se/ecommerce/service/impl/OrderServiceImpl.java index 27ad260..9a6edc1 100644 --- a/src/main/java/iuh/fit/se/ecommerce/service/impl/OrderServiceImpl.java +++ b/src/main/java/iuh/fit/se/ecommerce/service/impl/OrderServiceImpl.java @@ -342,7 +342,6 @@ private OrderDetailResponse mapToOrderDetailResponse(Order order) { .receiverEmail(null) // Address entity doesn't have email .country("Vietnam") .province(addr.getProvince()) - .district(addr.getDistrict()) .ward(addr.getWard()) .addressDetail(addr.getDetail()) .isDefault(addr.isDefault()) diff --git a/src/main/java/iuh/fit/se/ecommerce/service/impl/ProductServiceImpl.java b/src/main/java/iuh/fit/se/ecommerce/service/impl/ProductServiceImpl.java index 8b873eb..7d1259b 100644 --- a/src/main/java/iuh/fit/se/ecommerce/service/impl/ProductServiceImpl.java +++ b/src/main/java/iuh/fit/se/ecommerce/service/impl/ProductServiceImpl.java @@ -27,8 +27,10 @@ import java.io.IOException; import java.math.BigDecimal; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.stream.Collectors; @Service @@ -284,6 +286,163 @@ else if (qLower.contains("accessory") || qLower.contains("phụ kiện") || qLow .collect(Collectors.toList()); } + @Override + public Map searchAutocomplete(String query, int limit) { + if (query == null || query.trim().isEmpty()) { + Map result = new HashMap<>(); + result.put("products", List.of()); + result.put("totalCount", 0L); + return result; + } + + ProductSearchCriteria criteria = buildSearchCriteria(query); + Pageable pageable = PageRequest.of(0, limit); + Page page = productRepository.search(criteria, pageable); + + List products = page.getContent().stream() + .map(ProductMapper::toProductResponse) + .collect(Collectors.toList()); + + Map result = new HashMap<>(); + result.put("products", products); + result.put("totalCount", page.getTotalElements()); + return result; + } + + @Override + public Page searchProducts(String query, int page, int size, String sort) { + if (query == null || query.trim().isEmpty()) { + return Page.empty(PageRequest.of(page, size)); + } + + ProductSearchCriteria criteria = buildSearchCriteria(query); + Pageable pageable = PageRequest.of(page, size); + Page productPage = productRepository.search(criteria, pageable, sort); + + return productPage.map(ProductMapper::toProductResponse); + } + + private ProductSearchCriteria buildSearchCriteria(String query) { + String q = query.trim(); + String qLower = q.toLowerCase(Locale.ROOT); + + ProductSearchCriteria criteria = new ProductSearchCriteria(); + criteria.setText(null); + + // detect structured prefixes first + if (qLower.startsWith("brand:")) { + criteria.setBrand(q.substring("brand:".length()).trim()); + } else if (qLower.startsWith("type:")) { + criteria.setProductType(q.substring("type:".length()).trim()); + } else if (qLower.startsWith("price:")) { + String range = q.substring("price:".length()).trim(); + if (range.contains("-")) { + String[] parts = range.split("-", 2); + try { + criteria.setMinPrice(new BigDecimal(parts[0].trim())); + criteria.setMaxPrice(new BigDecimal(parts[1].trim())); + } catch (NumberFormatException ignored) {} + } + } else if (qLower.startsWith("spec:")) { + String term = q.substring("spec:".length()).trim(); + if (!term.isEmpty()) criteria.setSpecTerms(List.of(term)); + } else if (qLower.startsWith("promotion:")) { + criteria.setText(q); + } else { + // Natural-language parsing heuristics + String[] knownBrands = new String[]{"dell","hp","asus","acer","lenovo","apple","msi","lg"}; + for (String b : knownBrands) { + if (qLower.contains(b)) { + criteria.setBrand(b); + break; + } + } + + if (qLower.contains("gaming")) criteria.setProductType("GAMING"); + else if (qLower.contains("ultrabook")) criteria.setProductType("ULTRABOOK"); + else if (qLower.contains("workstation")) criteria.setProductType("WORKSTATION"); + else if (qLower.contains("accessory") || qLower.contains("phụ kiện") || qLower.contains("phu kien")) + criteria.setProductType("ACCESSORY"); + + // price patterns + try { + java.util.regex.Pattern pRange = java.util.regex.Pattern.compile("(\\d+(?:[.,]?\\d+)?)\\s*-\\s*(\\d+(?:[.,]?\\d+)?)(?:\\s*(triệu|m|vnđ|vnd))?", java.util.regex.Pattern.CASE_INSENSITIVE); + java.util.regex.Matcher mRange = pRange.matcher(qLower); + if (mRange.find()) { + String a = mRange.group(1).replaceAll("[.,]", ""); + String b = mRange.group(2).replaceAll("[.,]", ""); + String unit = mRange.group(3); + BigDecimal aVal = new BigDecimal(a); + BigDecimal bVal = new BigDecimal(b); + if (unit != null && unit.toLowerCase().contains("triệu")) { + aVal = aVal.multiply(BigDecimal.valueOf(1_000_000L)); + bVal = bVal.multiply(BigDecimal.valueOf(1_000_000L)); + } + criteria.setMinPrice(aVal); + criteria.setMaxPrice(bVal); + } else { + java.util.regex.Pattern pUnder = java.util.regex.Pattern.compile("dưới\\s+(\\d+(?:[.,]?\\d+)?)(?:\\s*(triệu|m|vnđ|vnd))?", java.util.regex.Pattern.CASE_INSENSITIVE); + java.util.regex.Matcher mUnder = pUnder.matcher(qLower); + if (mUnder.find()) { + String a = mUnder.group(1).replaceAll("[.,]", ""); + String unit = mUnder.group(2); + BigDecimal aVal = new BigDecimal(a); + if (unit != null && unit.toLowerCase().contains("triệu")) aVal = aVal.multiply(BigDecimal.valueOf(1_000_000L)); + criteria.setMaxPrice(aVal); + } else { + java.util.regex.Pattern pOver = java.util.regex.Pattern.compile("trên\\s+(\\d+(?:[.,]?\\d+)?)(?:\\s*(triệu|m|vnđ|vnd))?", java.util.regex.Pattern.CASE_INSENSITIVE); + java.util.regex.Matcher mOver = pOver.matcher(qLower); + if (mOver.find()) { + String a = mOver.group(1).replaceAll("[.,]", ""); + String unit = mOver.group(2); + BigDecimal aVal = new BigDecimal(a); + if (unit != null && unit.toLowerCase().contains("triệu")) aVal = aVal.multiply(BigDecimal.valueOf(1_000_000L)); + criteria.setMinPrice(aVal); + } else { + java.util.regex.Pattern pSingle = java.util.regex.Pattern.compile("(\\d+(?:[.,]?\\d+)?)\\s*(triệu|m|vnđ|vnd)", java.util.regex.Pattern.CASE_INSENSITIVE); + java.util.regex.Matcher mSingle = pSingle.matcher(qLower); + if (mSingle.find()) { + String a = mSingle.group(1).replaceAll("[.,]", ""); + String unit = mSingle.group(2); + BigDecimal aVal = new BigDecimal(a); + if (unit != null && unit.toLowerCase().contains("triệu")) aVal = aVal.multiply(BigDecimal.valueOf(1_000_000L)); + criteria.setMinPrice(aVal.multiply(BigDecimal.valueOf(8)).divide(BigDecimal.valueOf(10))); + criteria.setMaxPrice(aVal.multiply(BigDecimal.valueOf(12)).divide(BigDecimal.valueOf(10))); + } + } + } + } + } catch (Exception ignored) { + // ignore parse exceptions + } + + // spec terms + List specTerms = new ArrayList<>(); + java.util.regex.Pattern pSpecGb = java.util.regex.Pattern.compile("(\\d+)\\s*gb", java.util.regex.Pattern.CASE_INSENSITIVE); + java.util.regex.Matcher mSpecGb = pSpecGb.matcher(qLower); + while (mSpecGb.find()) { + specTerms.add(mSpecGb.group(0)); + } + if (qLower.contains("ram")) specTerms.add("ram"); + if (qLower.contains("ssd")) specTerms.add("ssd"); + if (qLower.contains("hdd")) specTerms.add("hdd"); + if (qLower.contains("cpu") || qLower.contains("core") || qLower.contains("intel") || qLower.contains("amd")) specTerms.add("cpu"); + if (qLower.contains("vga") || qLower.contains("card")) specTerms.add("vga"); + if (qLower.contains("màn hình") || qLower.contains("man hinh") || qLower.contains("display")) specTerms.add("màn hình"); + if (qLower.contains("120hz") || qLower.contains("144hz") || qLower.contains("165hz") || qLower.contains("240hz")) specTerms.add("tần số quét cao"); + if (qLower.contains("oled") || qLower.contains("ips") || qLower.contains("amoled")) specTerms.add("công nghệ màn hình"); + + if (!specTerms.isEmpty()) criteria.setSpecTerms(specTerms); + + // if no structured filters detected, set text for free-text search + if (criteria.getBrand() == null && criteria.getProductType() == null && criteria.getMinPrice() == null && criteria.getMaxPrice() == null && (criteria.getSpecTerms() == null || criteria.getSpecTerms().isEmpty())) { + criteria.setText(q); + } + } + + return criteria; + } + public void mapCreateRequestToProduct(Product product, ProductRequest request) { product.setName(request.getName()); product.setBrand(request.getBrand()); diff --git a/src/main/java/iuh/fit/se/ecommerce/service/interfaces/AddressService.java b/src/main/java/iuh/fit/se/ecommerce/service/interfaces/AddressService.java new file mode 100644 index 0000000..8a5894f --- /dev/null +++ b/src/main/java/iuh/fit/se/ecommerce/service/interfaces/AddressService.java @@ -0,0 +1,16 @@ +package iuh.fit.se.ecommerce.service.interfaces; + +import iuh.fit.se.ecommerce.dto.request.AddressRequest; +import iuh.fit.se.ecommerce.dto.response.AddressResponse; + +import java.util.List; + +public interface AddressService { + List getUserAddresses(String userEmail); + AddressResponse getAddressById(Long addressId, String userEmail); + AddressResponse createAddress(AddressRequest request, String userEmail); + AddressResponse updateAddress(Long addressId, AddressRequest request, String userEmail); + void deleteAddress(Long addressId, String userEmail); + AddressResponse setDefaultAddress(Long addressId, String userEmail); +} + diff --git a/src/main/java/iuh/fit/se/ecommerce/service/interfaces/NominatimService.java b/src/main/java/iuh/fit/se/ecommerce/service/interfaces/NominatimService.java new file mode 100644 index 0000000..0285163 --- /dev/null +++ b/src/main/java/iuh/fit/se/ecommerce/service/interfaces/NominatimService.java @@ -0,0 +1,9 @@ +package iuh.fit.se.ecommerce.service.interfaces; + +import iuh.fit.se.ecommerce.dto.response.AddressGeocodeResponse; +import java.math.BigDecimal; + +public interface NominatimService { + AddressGeocodeResponse reverseGeocode(BigDecimal lat, BigDecimal lng); +} + diff --git a/src/main/java/iuh/fit/se/ecommerce/service/interfaces/ProductService.java b/src/main/java/iuh/fit/se/ecommerce/service/interfaces/ProductService.java index 0b65d62..cba60e9 100644 --- a/src/main/java/iuh/fit/se/ecommerce/service/interfaces/ProductService.java +++ b/src/main/java/iuh/fit/se/ecommerce/service/interfaces/ProductService.java @@ -3,8 +3,10 @@ import iuh.fit.se.ecommerce.dto.request.ProductRequest; import iuh.fit.se.ecommerce.dto.response.ProductDetailResponse; import iuh.fit.se.ecommerce.dto.response.ProductResponse; +import org.springframework.data.domain.Page; import java.util.List; +import java.util.Map; public interface ProductService { ProductResponse createProduct(ProductRequest request); @@ -15,4 +17,6 @@ public interface ProductService { List getProductsByType(String type); List findByQuery(String query); List getHotSaleProducts(int limit); + Map searchAutocomplete(String query, int limit); + Page searchProducts(String query, int page, int size, String sort); } diff --git a/src/main/resources/static/css/style.css b/src/main/resources/static/css/style.css index 1676c18..7e11a68 100644 --- a/src/main/resources/static/css/style.css +++ b/src/main/resources/static/css/style.css @@ -95,29 +95,35 @@ body { right: -11px; background-color: #dc3545; color: white; - border-radius: 50%; - padding: 6px 6px; + border-radius: 5px; + padding: 4px 6px; font-size: 0.7rem; } -/* Notification bell styles */ -.notification-bell { +/* Notification icon */ +.notification-icon { position: relative; - display: inline-block; -} - -.notification-bell #notification-bell { + font-size: 1.5rem; color: var(--text-dark); text-decoration: none; - position: relative; + display: inline-block; } -.notification-bell .badge { +.notification-icon .badge { position: absolute; - top: -6px; - right: -6px; + top: -4px; + right: -11px; + background-color: #dc3545; + color: white; + border-radius: 5px; padding: 4px 6px; - font-size: 0.65rem; + font-size: 0.7rem; +} + +/* Notification bell container - để chứa dropdown */ +.notification-bell-container { + position: relative; + display: inline-block; } .notification-dropdown .card { @@ -2635,3 +2641,271 @@ body { margin: 0; } +/* Search Dropdown Styles */ +.search-box { + position: relative; +} + +.search-dropdown { + position: absolute; + top: 100%; + left: 0; + min-width: 600px; + max-width: 800px; + width: max-content; + background: white; + border: 1px solid #ddd; + border-radius: 8px; + box-shadow: 0 8px 16px rgba(0,0,0,0.15); + z-index: 1000; + max-height: 600px; + overflow-y: auto; + display: none; + margin-top: 4px; +} + +.search-dropdown.show { + display: block; +} + +.search-dropdown-item { + padding: 16px; + border-bottom: 1px solid #eee; + cursor: pointer; + display: flex; + align-items: flex-start; + gap: 16px; + transition: background-color 0.2s; +} + +.search-dropdown-item:hover { + background: #f5f5f5; +} + +.search-dropdown-item:last-child { + border-bottom: none; +} + +.search-dropdown-item-image { + width: 100px; + height: 100px; + object-fit: cover; + border-radius: 6px; + flex-shrink: 0; + border: 1px solid #eee; +} + +.search-dropdown-item-content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.search-dropdown-item-brand { + font-size: 0.75rem; + color: #667eea; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.search-dropdown-item-name { + font-size: 0.95rem; + font-weight: 500; + color: #333; + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; +} + +.search-dropdown-item-price { + font-size: 0.9rem; + margin-top: 4px; +} + +.search-dropdown-item-discount { + display: inline-block; + background: #e74c3c; + color: white; + font-size: 0.7rem; + font-weight: 600; + padding: 2px 6px; + border-radius: 3px; + margin-right: 8px; +} + +.search-dropdown-item-price .price-old { + text-decoration: line-through; + color: #999; + margin-right: 8px; +} + +.search-dropdown-item-price .price-new { + color: #e74c3c; + font-weight: 600; +} + +.search-dropdown-view-more { + padding: 12px; + text-align: center; + background: #f8f9fa; + cursor: pointer; + font-weight: 600; + color: #667eea; + border-top: 1px solid #eee; + transition: background-color 0.2s; +} + +.search-dropdown-view-more:hover { + background: #e9ecef; +} + +.search-dropdown-empty { + padding: 20px; + text-align: center; + color: #6c757d; +} + +/* Search Results Page Styles */ +.search-query-display { + font-size: 1rem; + color: #495057; +} + +.sort-btn { + border: 1px solid #dee2e6; + transition: all 0.2s; +} + +.sort-btn:hover { + border-color: #667eea; + color: #667eea; +} + +.sort-btn.active { + background-color: #667eea; + border-color: #667eea; + color: white; +} + +.products-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 20px; + margin-bottom: 2rem; +} + +@media (max-width: 768px) { + .products-grid { + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 15px; + } + + .search-dropdown { + min-width: calc(100vw - 40px); + max-width: calc(100vw - 40px); + left: -20px; + right: -20px; + } + + .search-dropdown-item { + padding: 12px; + gap: 12px; + } + + .search-dropdown-item-image { + width: 80px; + height: 80px; + } + + .search-dropdown-item-name { + font-size: 0.85rem; + -webkit-line-clamp: 2; + line-clamp: 2; + } + + .search-dropdown-item-brand { + font-size: 0.7rem; + } +} + +/* Address Selection Styles */ +.address-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.address-card { + border: 2px solid #e0e0e0; + border-radius: 8px; + padding: 16px; + cursor: pointer; + transition: all 0.3s; + background: #fff; +} + +.address-card:hover { + border-color: #ffd939; + box-shadow: 0 2px 8px rgba(255, 217, 57, 0.1); +} + +.address-card.border-primary { + border-color: #ffd939; + background: #fffef5; +} + +/* Address mặc định - highlight vàng */ +.address-card.border-warning { + border-color: #ffc107 !important; + border-width: 2px !important; + background: #fffbf0 !important; + box-shadow: 0 2px 8px rgba(255, 193, 7, 0.2) !important; +} + +.address-card input[type="radio"] { + margin-top: 4px; +} + +/* No Address Alert */ +#no-address-alert { + border-left: 4px solid #ff9800; + background: #fff3e0; +} + +/* Address Item in Profile */ +.address-item { + transition: all 0.3s; +} + +.address-item:hover { + box-shadow: 0 2px 8px rgba(0,0,0,0.1); +} + +/* Address mặc định trong Profile - highlight vàng */ +.address-item.border-warning { + border-color: #ffc107 !important; + border-width: 2px !important; + background: #fffbf0 !important; + box-shadow: 0 2px 8px rgba(255, 193, 7, 0.2) !important; +} + +/* Address mặc định trong Profile - highlight vàng */ +.address-item.border-warning { + border-color: #ffc107; + border-width: 2px; + background: #fffbf0; + box-shadow: 0 2px 8px rgba(255, 193, 7, 0.2); +} + +/* Map Container */ +#address-map { + border-radius: 8px; +} + diff --git a/src/main/resources/static/fragments/header.html b/src/main/resources/static/fragments/header.html index 17aeca5..949e96a 100644 --- a/src/main/resources/static/fragments/header.html +++ b/src/main/resources/static/fragments/header.html @@ -101,23 +101,15 @@ 0 -
+
- - 0 + + 0
{ + if (!id || !userAddresses) return null; + return userAddresses.find(a => a.id == id || String(a.id) === String(id)) || null; + }; + const address = isEdit ? findAddress(addressId) : null; + + const modalHTML = ` + + `; + + // Remove existing modal + const existing = document.getElementById('addressModal'); + if (existing) existing.remove(); + + document.body.insertAdjacentHTML('beforeend', modalHTML); + const modal = new bootstrap.Modal(document.getElementById('addressModal')); + + // Initialize map after modal is shown + modal._element.addEventListener('shown.bs.modal', () => { + const enableMap = document.getElementById('enable-map-picker').checked; + if (enableMap) { + const initialLat = address?.latitude || 10.7769; + const initialLng = address?.longitude || 106.7009; + initMapPicker('address-map', initialLat, initialLng, address ? 15 : 13); + addressMapInitialized = true; + } + }, { once: true }); + + // Cleanup when modal is hidden + modal._element.addEventListener('hidden.bs.modal', () => { + if (addressMapInitialized) { + destroyMapPicker(); + addressMapInitialized = false; + } + }); + + modal.show(); +}; + +/** + * Toggle map picker + */ +function toggleMapPicker() { + const enableMap = document.getElementById('enable-map-picker').checked; + const mapSection = document.getElementById('map-picker-section'); + + if (enableMap) { + mapSection.style.display = 'block'; + + // Initialize map nếu chưa có + if (!addressMapInitialized) { + const addressId = document.getElementById('address-id').value; + const findAddress = (id) => { + if (!id || !userAddresses) return null; + return userAddresses.find(a => a.id == id || String(a.id) === String(id)) || null; + }; + const address = addressId ? findAddress(addressId) : null; + const initialLat = address?.latitude || 10.7769; + const initialLng = address?.longitude || 106.7009; + initMapPicker('address-map', initialLat, initialLng, address ? 15 : 13); + addressMapInitialized = true; + } + } else { + mapSection.style.display = 'none'; + + // Clear coordinates khi tắt map + if (addressMapInitialized) { + destroyMapPicker(); + addressMapInitialized = false; + } + } +} + +/** + * Clear map selection + */ +function clearMapSelection() { + if (marker) { + destroyMapPicker(); + addressMapInitialized = false; + // Re-init với vị trí mặc định + initMapPicker('address-map', 10.7769, 106.7009, 13); + addressMapInitialized = true; + } +} + +/** + * Callback khi geocode thành công + * Khai báo global để map-picker.js có thể gọi + */ +window.onAddressGeocoded = function(response) { + console.log('Geocoded response:', response); + + // Luôn điền vào form (không check empty) + // Địa chỉ chi tiết: số nhà + đường + const detailField = document.getElementById('address-detail'); + if (detailField) { + let detailValue = ''; + if (response.houseNumber) { + detailValue = response.houseNumber; + } + if (response.road) { + detailValue = detailValue ? detailValue + ' ' + response.road : response.road; + } + if (detailValue) { + detailField.value = detailValue; + // Highlight để user thấy đã được điền + detailField.classList.add('border-success'); + setTimeout(() => detailField.classList.remove('border-success'), 2000); + } + } + + // Phường/Xã + const wardField = document.getElementById('address-ward'); + if (wardField && response.ward) { + wardField.value = response.ward; + wardField.classList.add('border-success'); + setTimeout(() => wardField.classList.remove('border-success'), 2000); + } + + // Tỉnh/TP + const provinceField = document.getElementById('address-province'); + if (provinceField && response.province) { + provinceField.value = response.province; + provinceField.classList.add('border-success'); + setTimeout(() => provinceField.classList.remove('border-success'), 2000); + } + + // Hiển thị thông báo để user biết đã điền + if (response.fullAddress) { + showAlert('Đã điền địa chỉ: ' + response.fullAddress, 'success'); + } +}; + +/** + * Callback khi geocode lỗi + */ +window.onGeocodeError = function(error) { + console.error('Geocoding error:', error); + showAlert('Không thể lấy địa chỉ từ vị trí đã chọn', 'warning'); +}; + +/** + * Get current location (browser geolocation) + */ +function getCurrentLocation() { + if (!navigator.geolocation) { + showAlert('Trình duyệt không hỗ trợ định vị', 'error'); + return; + } + + showAlert('Đang lấy vị trí...', 'info'); + + navigator.geolocation.getCurrentPosition( + (position) => { + const lat = position.coords.latitude; + const lng = position.coords.longitude; + + setCoordinates(lat, lng); + reverseGeocode(lat, lng); + showAlert('Đã lấy vị trí thành công', 'success'); + }, + (error) => { + console.error('Geolocation error:', error); + showAlert('Không thể lấy vị trí: ' + error.message, 'error'); + } + ); +} + +async function saveAddress() { + const form = document.getElementById('address-form'); + if (!form.checkValidity()) { + form.reportValidity(); + return; + } + + const addressId = document.getElementById('address-id').value; + const isEdit = addressId !== null && addressId !== ''; + const enableMap = document.getElementById('enable-map-picker').checked; + + let latitude = null, longitude = null; + if (enableMap && addressMapInitialized && marker) { + const coords = getCoordinates(); + if (coords) { + latitude = coords.lat; + longitude = coords.lng; + } + } + + const addressData = { + label: document.getElementById('address-label').value || null, + receiverName: document.getElementById('address-receiver-name').value, + receiverPhone: document.getElementById('address-receiver-phone').value, + province: document.getElementById('address-province').value, + ward: document.getElementById('address-ward').value, + detail: document.getElementById('address-detail').value, + isDefault: document.getElementById('address-is-default').checked, + latitude: latitude, + longitude: longitude + }; + + try { + if (isEdit) { + await apiClient.request(`/addresses/${addressId}`, { + method: 'PUT', + body: JSON.stringify(addressData) + }); + showAlert('Đã cập nhật địa chỉ', 'success'); + } else { + await apiClient.request('/addresses', { + method: 'POST', + body: JSON.stringify(addressData) + }); + showAlert('Đã thêm địa chỉ', 'success'); + } + + const modal = bootstrap.Modal.getInstance(document.getElementById('addressModal')); + modal.hide(); + + // Reload addresses - ưu tiên loadUserAddresses (checkout) trước + // Nếu đang ở checkout page, chỉ reload checkout addresses + if (typeof loadUserAddresses === 'function') { + await loadUserAddresses(); + // loadUserAddresses() sẽ tự động chọn default address hoặc address đầu tiên + } else if (typeof loadAddresses === 'function') { + // Nếu không phải checkout page, reload profile addresses + await loadAddresses(); + } + } catch (error) { + showAlert('Lỗi: ' + error.message, 'error'); + } +} + diff --git a/src/main/resources/static/js/api.js b/src/main/resources/static/js/api.js index 3393b34..fa03836 100644 --- a/src/main/resources/static/js/api.js +++ b/src/main/resources/static/js/api.js @@ -217,6 +217,20 @@ class ApiClient { }); } + async searchAutocomplete(query, limit = 5) { + return this.request(`/products/search/autocomplete?q=${encodeURIComponent(query)}&limit=${limit}`, { + method: 'GET', + skipAuth: true + }); + } + + async searchProducts(query, page = 0, size = 20, sort = 'default') { + return this.request(`/products/search?q=${encodeURIComponent(query)}&page=${page}&size=${size}&sort=${sort}`, { + method: 'GET', + skipAuth: true + }); + } + // Admin/Product modification APIs (use FormData because backend expects @ModelAttribute with files) async adminCreateProduct(formData) { const url = `${this.baseURL}/products`; diff --git a/src/main/resources/static/js/checkout.js b/src/main/resources/static/js/checkout.js index 6be4174..80afe2c 100644 --- a/src/main/resources/static/js/checkout.js +++ b/src/main/resources/static/js/checkout.js @@ -1,13 +1,11 @@ -// checkout.js - handles checkout page logic +let cart = null; document.addEventListener('DOMContentLoaded', async function() { - // Check if apiClient is available if (typeof apiClient === 'undefined') { console.error('apiClient not found. Make sure /js/api.js is loaded before /js/checkout.js'); return; } - // Check authentication if (!apiClient.isAuthenticated()) { document.getElementById('login-banner').classList.remove('d-none'); showAlert('Vui lòng đăng nhập để thanh toán', 'warning'); @@ -16,9 +14,7 @@ document.addEventListener('DOMContentLoaded', async function() { let currentUser = apiClient.getUser(); let userId = null; - let cart = null; - // Get user info if (currentUser && currentUser.id) { userId = currentUser.id; } else { @@ -44,18 +40,16 @@ document.addEventListener('DOMContentLoaded', async function() { } } - // Load cart await loadCart(userId); - // Populate user info if available if (currentUser) { populateUserInfo(currentUser); } - // Setup event listeners setupEventListeners(); }); + async function loadCart(userId) { try { cart = await apiClient.request(`/cart/${userId}`, { method: 'GET' }); @@ -86,6 +80,7 @@ async function loadCart(userId) { } } + function renderCartItems(items) { const container = document.getElementById('cart-items-list'); container.innerHTML = ''; @@ -136,15 +131,15 @@ function updateOrderSummary(cart) { } function populateUserInfo(user) { + if (user.email) { + document.getElementById('receiverEmail').value = user.email; + } if (user.fullName) { document.getElementById('receiverName').value = user.fullName; } if (user.phone) { document.getElementById('receiverPhone').value = user.phone; } - if (user.email) { - document.getElementById('receiverEmail').value = user.email; - } } function setupEventListeners() { @@ -180,16 +175,39 @@ async function handlePlaceOrder() { btn.textContent = 'Đang xử lý...'; try { - // Validate form if (!validateShippingForm()) { btn.disabled = false; btn.textContent = originalText; return; } - // Get selected payment method const paymentMethod = document.querySelector('input[name="paymentMethod"]:checked').value; + const addressData = { + receiverName: document.getElementById('receiverName').value.trim(), + receiverPhone: document.getElementById('receiverPhone').value.trim(), + province: document.getElementById('province').value.trim(), + ward: document.getElementById('ward').value.trim(), + detail: document.getElementById('addressDetail').value.trim(), + isDefault: false + }; + + let shippingAddressId = null; + + try { + const newAddress = await apiClient.request('/addresses', { + method: 'POST', + body: JSON.stringify(addressData) + }); + shippingAddressId = newAddress.id; + } catch (error) { + console.error('Error creating address:', error); + showAlert('Cảnh báo: Không thể tạo địa chỉ. Vui lòng thử lại.', 'warning'); + btn.disabled = false; + btn.textContent = originalText; + return; + } + // Build payment request const paymentRequest = { items: cart.items.map(item => ({ @@ -200,7 +218,7 @@ async function handlePlaceOrder() { })), paymentMethod: paymentMethod, notes: document.getElementById('orderNotes').value || null, - shippingAddressId: null // TODO: Save address and use ID + shippingAddressId: shippingAddressId }; // Create payment @@ -234,37 +252,52 @@ async function handlePlaceOrder() { } function validateShippingForm() { - const requiredFields = ['receiverName', 'receiverPhone', 'receiverEmail', 'addressDetail', 'province', 'district', 'ward']; + // Helper để validate field + const validateField = (fieldId, isRequired = true) => { + const field = document.getElementById(fieldId); + if (!field) return false; + + const value = field.value.trim(); + if (isRequired && !value) { + field.classList.add('is-invalid'); + return false; + } + field.classList.remove('is-invalid'); + return true; + }; + + // Validate thông tin người nhận và địa chỉ + const requiredFields = ['receiverName', 'receiverPhone', 'receiverEmail', 'addressDetail', 'ward', 'province']; let isValid = true; requiredFields.forEach(fieldId => { - const field = document.getElementById(fieldId); - if (!field.value.trim()) { - field.classList.add('is-invalid'); + if (!validateField(fieldId, true)) { isValid = false; - } else { - field.classList.remove('is-invalid'); } }); // Validate email format - const email = document.getElementById('receiverEmail').value; - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (email && !emailRegex.test(email)) { - document.getElementById('receiverEmail').classList.add('is-invalid'); - isValid = false; + const emailField = document.getElementById('receiverEmail'); + if (emailField && emailField.value.trim()) { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(emailField.value.trim())) { + emailField.classList.add('is-invalid'); + isValid = false; + } } // Validate phone format (Vietnamese phone) - const phone = document.getElementById('receiverPhone').value; - const phoneRegex = /^(0|\+84)[3-9]\d{8}$/; - if (phone && !phoneRegex.test(phone.replace(/\s/g, ''))) { - document.getElementById('receiverPhone').classList.add('is-invalid'); - isValid = false; + const phoneField = document.getElementById('receiverPhone'); + if (phoneField && phoneField.value.trim()) { + const phoneRegex = /^(0|\+84)[3-9]\d{8}$/; + if (!phoneRegex.test(phoneField.value.replace(/\s/g, ''))) { + phoneField.classList.add('is-invalid'); + isValid = false; + } } if (!isValid) { - showAlert('Vui lòng điền đầy đủ và đúng định dạng thông tin bắt buộc', 'error'); + showAlert('Vui lòng điền đầy đủ và đúng định dạng thông tin giao hàng', 'error'); } return isValid; diff --git a/src/main/resources/static/js/common.js b/src/main/resources/static/js/common.js index 4396857..1b8ec50 100644 --- a/src/main/resources/static/js/common.js +++ b/src/main/resources/static/js/common.js @@ -252,6 +252,9 @@ function initializeHeader() { badge.textContent = "0"; } } + + // Initialize search autocomplete + initSearchAutocomplete(); } function updateUserMenu() { @@ -458,13 +461,175 @@ function showOrders() { function handleSearch() { const searchInput = document.getElementById("search-input"); if (searchInput) { - const query = searchInput.value; - if (query.trim()) { - window.location.href = `/index.html?search=${encodeURIComponent(query)}`; + const query = searchInput.value.trim(); + if (query) { + // Hide dropdown if open + hideSearchDropdown(); + // Navigate to search results page + window.location.href = `/search-results.html?q=${encodeURIComponent(query)}`; } } } +// Search Autocomplete Functions +let searchTimeout = null; +let searchDropdown = null; + +function initSearchAutocomplete() { + const searchInput = document.getElementById('search-input'); + if (!searchInput) return; + + // Create dropdown container + createSearchDropdown(); + + // Event listeners + searchInput.addEventListener('input', handleSearchInput); + searchInput.addEventListener('focus', handleSearchFocus); + searchInput.addEventListener('keydown', handleSearchKeydown); + + // Click outside to close dropdown + document.addEventListener('click', handleClickOutside); +} + +function createSearchDropdown() { + const searchBox = document.querySelector('.search-box'); + if (!searchBox || document.getElementById('search-dropdown')) return; + + searchDropdown = document.createElement('div'); + searchDropdown.id = 'search-dropdown'; + searchDropdown.className = 'search-dropdown'; + searchBox.style.position = 'relative'; + searchBox.appendChild(searchDropdown); +} + +async function handleSearchInput(e) { + const query = e.target.value.trim(); + + clearTimeout(searchTimeout); + + if (query.length < 2) { + hideSearchDropdown(); + return; + } + + searchTimeout = setTimeout(async () => { + await loadSearchAutocomplete(query); + }, 300); +} + +function handleSearchFocus(e) { + const query = e.target.value.trim(); + if (query.length >= 2) { + loadSearchAutocomplete(query); + } +} + +function handleSearchKeydown(e) { + if (e.key === 'Enter') { + e.preventDefault(); + handleSearch(); + } else if (e.key === 'Escape') { + hideSearchDropdown(); + } +} + +function handleClickOutside(e) { + const searchBox = document.querySelector('.search-box'); + if (searchBox && !searchBox.contains(e.target)) { + hideSearchDropdown(); + } +} + +async function loadSearchAutocomplete(query) { + if (!searchDropdown) return; + + try { + const response = await apiClient.searchAutocomplete(query, 5); + const { products, totalCount } = response; + + displaySearchDropdown(products, totalCount, query); + } catch (error) { + console.error('Search autocomplete error:', error); + hideSearchDropdown(); + } +} + +function displaySearchDropdown(products, totalCount, query) { + if (!searchDropdown) return; + + if (products.length === 0 && totalCount === 0) { + searchDropdown.innerHTML = ` +
+

Không tìm thấy sản phẩm nào

+
+ `; + searchDropdown.classList.add('show'); + return; + } + + let html = ''; + + // Display products (max 5) + products.forEach(product => { + const discountPercent = product.priceAfterDiscount && product.priceAfterDiscount < product.price + ? Math.round((1 - product.priceAfterDiscount / product.price) * 100) + : 0; + + // Get short description (first 80 characters) + const shortDesc = product.description + ? (product.description.length > 80 ? product.description.substring(0, 80) + '...' : product.description) + : ''; + + html += ` +
+ ${product.name} +
+ ${product.brand ? `
${product.brand}
` : ''} +
${product.name}
+ ${shortDesc ? `
${shortDesc}
` : ''} +
+ ${discountPercent > 0 ? `-${discountPercent}%` : ''} + ${product.priceAfterDiscount && product.priceAfterDiscount < product.price + ? `${formatPrice(product.price)} + ${formatPrice(product.priceAfterDiscount)}` + : `${formatPrice(product.price)}` + } +
+
+
+ `; + }); + + // Show "View more" button if there are more products + const remainingCount = totalCount - products.length; + if (remainingCount > 0) { + html += ` +
+ Xem thêm ${remainingCount} sản phẩm +
+ `; + } + + searchDropdown.innerHTML = html; + searchDropdown.classList.add('show'); +} + +function hideSearchDropdown() { + if (searchDropdown) { + searchDropdown.classList.remove('show'); + } +} + +function navigateToProduct(productId) { + hideSearchDropdown(); + window.location.href = `/product-detail.html?id=${productId}`; +} + +function navigateToSearchResults(query) { + hideSearchDropdown(); + window.location.href = `/search-results.html?q=${encodeURIComponent(query)}`; +} + function showLoginModal() { const modal = document.getElementById("loginModal"); if (modal) { diff --git a/src/main/resources/static/js/map-picker.js b/src/main/resources/static/js/map-picker.js new file mode 100644 index 0000000..0068669 --- /dev/null +++ b/src/main/resources/static/js/map-picker.js @@ -0,0 +1,134 @@ +// map-picker.js - Leaflet map picker với Nominatim integration + +let map = null; +let marker = null; +let debounceTimer = null; +let isInitialized = false; + +/** + * Initialize Leaflet map + */ +function initMapPicker(containerId, initialLat = 10.7769, initialLng = 106.7009, initialZoom = 13) { + if (isInitialized) { + console.warn('Map already initialized'); + return; + } + + // Initialize map + map = L.map(containerId).setView([initialLat, initialLng], initialZoom); + + // Add OpenStreetMap tile layer + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + maxZoom: 19 + }).addTo(map); + + // Add initial marker + marker = L.marker([initialLat, initialLng], { draggable: true }).addTo(map); + + // Click event + map.on('click', function(e) { + const lat = e.latlng.lat; + const lng = e.latlng.lng; + updateMarker(lat, lng); + reverseGeocode(lat, lng); + }); + + // Drag event với debounce + marker.on('dragend', function(e) { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + const pos = e.target.getLatLng(); + reverseGeocode(pos.lat, pos.lng); + }, 500); // Debounce 500ms + }); + + isInitialized = true; +} + +/** + * Update marker position + */ +function updateMarker(lat, lng) { + if (marker) { + marker.setLatLng([lat, lng]); + } else { + marker = L.marker([lat, lng], { draggable: true }).addTo(map); + } + map.setView([lat, lng], map.getZoom()); +} + +/** + * Get current coordinates + */ +function getCoordinates() { + if (!marker) return null; + const pos = marker.getLatLng(); + return { + lat: pos.lat, + lng: pos.lng + }; +} + +/** + * Set coordinates programmatically + */ +function setCoordinates(lat, lng) { + updateMarker(lat, lng); + if (map) { + map.setView([lat, lng], 15); + } +} + +/** + * Reverse geocode từ backend API + */ +async function reverseGeocode(lat, lng) { + // Throttle: max 1 request/second + const now = Date.now(); + if (window.lastGeocodeTime && (now - window.lastGeocodeTime < 1000)) { + console.log('Throttled: Skipping geocode request'); + return; + } + window.lastGeocodeTime = now; + + try { + console.log('Calling geocoding API for lat:', lat, 'lng:', lng); + + const response = await apiClient.request( + `/geocoding/reverse?lat=${lat}&lng=${lng}`, + { method: 'GET' } + ); + + console.log('Geocoding response:', response); + + if (response && response.fullAddress) { + // Gọi callback global + if (typeof window.onAddressGeocoded === 'function') { + window.onAddressGeocoded(response); + } else { + console.warn('onAddressGeocoded callback not found'); + } + } else { + console.warn('No address found in geocoding response'); + } + } catch (error) { + console.error('Geocoding error:', error); + if (typeof window.onGeocodeError === 'function') { + window.onGeocodeError(error); + } + } +} + +/** + * Destroy map instance + */ +function destroyMapPicker() { + if (map) { + map.remove(); + map = null; + marker = null; + isInitialized = false; + } +} + diff --git a/src/main/resources/static/js/order-detail.js b/src/main/resources/static/js/order-detail.js index 6395ce5..91d5fe7 100644 --- a/src/main/resources/static/js/order-detail.js +++ b/src/main/resources/static/js/order-detail.js @@ -134,7 +134,7 @@ function renderShippingAddress(address) {

${address.receiverName}

${address.receiverPhone}

${address.addressDetail}

-

${address.ward}, ${address.district}, ${address.province}

+

${address.ward}, ${address.province}

`; } diff --git a/src/main/resources/static/js/search-results.js b/src/main/resources/static/js/search-results.js new file mode 100644 index 0000000..2ec3e15 --- /dev/null +++ b/src/main/resources/static/js/search-results.js @@ -0,0 +1,245 @@ +// Search Results Page Logic +let currentQuery = ''; +let currentPage = 0; +let currentSort = 'default'; +let totalProducts = 0; +let totalPages = 0; +const pageSize = 20; + +// Initialize page +document.addEventListener('DOMContentLoaded', function() { + const urlParams = new URLSearchParams(window.location.search); + currentQuery = urlParams.get('q') || ''; + currentPage = parseInt(urlParams.get('page') || '0'); + currentSort = urlParams.get('sort') || 'default'; + + if (currentQuery) { + document.getElementById('search-query').textContent = currentQuery; + loadSearchResults(); + setupSortButtons(); + } else { + showError('Vui lòng nhập từ khóa tìm kiếm'); + } +}); + +function setupSortButtons() { + const sortButtons = document.querySelectorAll('.sort-btn'); + sortButtons.forEach(btn => { + btn.addEventListener('click', function() { + const sort = this.getAttribute('data-sort'); + changeSort(sort); + }); + }); + + // Set active button + updateActiveSortButton(); +} + +function updateActiveSortButton() { + const sortButtons = document.querySelectorAll('.sort-btn'); + sortButtons.forEach(btn => { + btn.classList.remove('btn-primary', 'active'); + btn.classList.add('btn-outline-secondary'); + + if (btn.getAttribute('data-sort') === currentSort) { + btn.classList.remove('btn-outline-secondary'); + btn.classList.add('btn-primary', 'active'); + } + }); +} + +function changeSort(sort) { + currentSort = sort; + currentPage = 0; + updateURL(); + loadSearchResults(); + updateActiveSortButton(); +} + +async function loadSearchResults() { + const loadingEl = document.getElementById('search-loading'); + const gridEl = document.getElementById('search-results-grid'); + const emptyState = document.getElementById('empty-state'); + const summaryEl = document.getElementById('search-summary'); + + if (loadingEl) loadingEl.style.display = 'block'; + if (gridEl) gridEl.innerHTML = ''; + if (emptyState) emptyState.classList.add('d-none'); + + try { + const response = await apiClient.searchProducts(currentQuery, currentPage, pageSize, currentSort); + + totalProducts = response.totalElements || 0; + totalPages = response.totalPages || 0; + + // Update summary + if (summaryEl) { + summaryEl.innerHTML = `Có ${totalProducts} sản phẩm cho tìm kiếm`; + } + + const products = response.content || []; + + if (products.length === 0) { + if (emptyState) emptyState.classList.remove('d-none'); + renderPagination(0, 0); + } else { + displayProducts(products); + renderPagination(totalPages, totalProducts); + } + } catch (error) { + console.error('Error loading search results:', error); + showAlert('Không thể tải kết quả tìm kiếm: ' + error.message, 'error'); + if (emptyState) emptyState.classList.remove('d-none'); + } finally { + if (loadingEl) loadingEl.style.display = 'none'; + } +} + +function displayProducts(products) { + const gridEl = document.getElementById('search-results-grid'); + if (!gridEl) return; + + gridEl.innerHTML = products.map(product => { + const discountPercent = product.priceAfterDiscount && product.priceAfterDiscount < product.price + ? Math.round((1 - product.priceAfterDiscount / product.price) * 100) + : 0; + + return ` +
+
+ ${discountPercent > 0 ? + `-${discountPercent}%` : ''} + + ${product.name} + +
+
${product.name}
+
+ ${product.priceAfterDiscount && product.priceAfterDiscount < product.price ? + `${formatPrice(product.price)} + ${formatPrice(product.priceAfterDiscount)}` : + `${formatPrice(product.price)}` + } +
+ +
+
+
+ `; + }).join(''); +} + +function renderPagination(totalPages, totalElements) { + const pagination = document.getElementById('pagination'); + if (!pagination) return; + + if (totalPages <= 1) { + pagination.innerHTML = ''; + return; + } + + let html = ''; + + // Previous button + html += ` +
  • + Trước +
  • + `; + + // Page numbers + for (let i = 0; i < totalPages; i++) { + if (i === 0 || i === totalPages - 1 || (i >= currentPage - 2 && i <= currentPage + 2)) { + html += ` +
  • + ${i + 1} +
  • + `; + } else if (i === currentPage - 3 || i === currentPage + 3) { + html += '
  • ...
  • '; + } + } + + // Next button + html += ` +
  • + Sau +
  • + `; + + pagination.innerHTML = html; +} + +function changePage(page) { + if (page < 0 || page >= totalPages) return; + currentPage = page; + updateURL(); + loadSearchResults(); + // Scroll to top + window.scrollTo({ top: 0, behavior: 'smooth' }); +} + +function updateURL() { + const params = new URLSearchParams(); + params.set('q', currentQuery); + if (currentPage > 0) params.set('page', currentPage); + if (currentSort !== 'default') params.set('sort', currentSort); + + const newURL = `/search-results.html?${params.toString()}`; + window.history.pushState({}, '', newURL); +} + +async function addToCart(productId) { + if (!apiClient.isAuthenticated()) { + showAlert('Vui lòng đăng nhập để thêm sản phẩm vào giỏ hàng', 'warning'); + setTimeout(() => showLoginModal(), 1000); + return; + } + + try { + // Determine userId + let user = apiClient.getUser(); + if (!user || !user.id) { + user = await apiClient.getProfile(); + if (user) apiClient.setUser(user); + } + const userId = user && user.id; + if (!userId) { + showAlert('Không xác định được người dùng. Vui lòng đăng nhập lại.', 'error'); + setTimeout(() => showLoginModal(), 800); + return; + } + + const payload = { userId: Number(userId), productId: Number(productId), quantity: 1 }; + await apiClient.request('/cart/add', { + method: 'POST', + body: JSON.stringify(payload) + }); + + // Update cart badge + if (typeof updateCartBadge === 'function') { + await updateCartBadge(); + } + + showAlert('Sản phẩm đã được thêm vào giỏ hàng', 'success'); + } catch (err) { + console.error('addToCart error', err); + showAlert('Thêm vào giỏ hàng thất bại: ' + (err.message || err), 'error'); + } +} + +function showError(message) { + const gridEl = document.getElementById('search-results-grid'); + if (gridEl) { + gridEl.innerHTML = ` + + `; + } +} + diff --git a/src/main/resources/templates/checkout/checkout.html b/src/main/resources/templates/checkout/checkout.html index a0b7d70..ac93692 100644 --- a/src/main/resources/templates/checkout/checkout.html +++ b/src/main/resources/templates/checkout/checkout.html @@ -36,7 +36,10 @@

    Thanh toán

    Thông tin giao hàng
    + +
    +
    Thông tin người nhận
    Thông tin giao hàng
    + +
    Địa chỉ giao hàng
    - - -
    -
    - + + placeholder="Số nhà, tên đường" required>
    -
    - - -
    -
    - - -
    -
    +
    + placeholder="Nhập phường/xã" required> +
    +
    + +
    @@ -195,8 +190,13 @@
    Tóm tắt đơn hàng
    + + + + + diff --git a/src/main/resources/templates/search-results.html b/src/main/resources/templates/search-results.html new file mode 100644 index 0000000..57b8bac --- /dev/null +++ b/src/main/resources/templates/search-results.html @@ -0,0 +1,86 @@ + + + + + + Tìm kiếm - E-Commerce + + + + + + + + + + + +
    + + +
    + +
    +

    Tìm kiếm

    +

    0 sản phẩm cho tìm kiếm

    +
    +

    Kết quả tìm kiếm cho ""

    +
    +
    + + +
    +
    + Sắp xếp theo: + + + +
    +
    + + +
    +
    + Loading... +
    +
    + + +
    + +
    + + +
    + +

    Không tìm thấy sản phẩm nào

    +
    + + + +
    + + + + + + + + + + + + + + + diff --git a/src/main/resources/templates/user/profile.html b/src/main/resources/templates/user/profile.html index 6b53c57..e234dce 100644 --- a/src/main/resources/templates/user/profile.html +++ b/src/main/resources/templates/user/profile.html @@ -34,6 +34,11 @@ Đổi mật khẩu +
    @@ -138,6 +143,23 @@
    Đổi mật khẩu
    + + +
    +
    +
    +
    Địa chỉ giao hàng
    + +
    +
    +
    + +
    +
    +
    +
    @@ -147,12 +169,21 @@
    Đổi mật khẩu
    + + + + + + + + +