From 015ffa82c5ec0d71fa4f9ec76932b87cd272cca3 Mon Sep 17 00:00:00 2001 From: BaoCoder288203 Date: Thu, 4 Dec 2025 16:42:24 +0700 Subject: [PATCH] feat: add address for user, add search by name product, optimize login --- .../fit/se/ecommerce/config/CacheConfig.java | 20 + .../config/NominatimCacheKeyGenerator.java | 24 ++ .../se/ecommerce/config/SecurityConfig.java | 7 + .../controller/AddressController.java | 70 ++++ .../controller/GeocodingController.java | 27 ++ .../controller/ProductController.java | 17 + .../ecommerce/controller/WebController.java | 5 + .../ecommerce/dto/request/AddressRequest.java | 37 ++ .../dto/response/AddressGeocodeResponse.java | 24 ++ .../dto/response/AddressResponse.java | 4 +- .../iuh/fit/se/ecommerce/entity/Address.java | 17 +- .../repository/AddressRepository.java | 12 +- .../repository/ProductRepositoryCustom.java | 1 + .../repository/ProductRepositoryImpl.java | 158 ++++++++ .../service/impl/AddressServiceImpl.java | 169 +++++++++ .../service/impl/NominatimServiceImpl.java | 178 +++++++++ .../service/impl/OrderServiceImpl.java | 1 - .../service/impl/ProductServiceImpl.java | 159 ++++++++ .../service/interfaces/AddressService.java | 16 + .../service/interfaces/NominatimService.java | 9 + .../service/interfaces/ProductService.java | 4 + src/main/resources/static/css/style.css | 300 ++++++++++++++- .../resources/static/fragments/header.html | 16 +- src/main/resources/static/js/address-form.js | 350 ++++++++++++++++++ src/main/resources/static/js/api.js | 14 + src/main/resources/static/js/checkout.js | 95 +++-- src/main/resources/static/js/common.js | 171 ++++++++- src/main/resources/static/js/map-picker.js | 134 +++++++ src/main/resources/static/js/notifications.js | 16 +- src/main/resources/static/js/order-detail.js | 2 +- .../resources/static/js/search-results.js | 245 ++++++++++++ .../templates/checkout/checkout.html | 38 +- .../resources/templates/search-results.html | 86 +++++ .../resources/templates/user/profile.html | 134 +++++++ 34 files changed, 2469 insertions(+), 91 deletions(-) create mode 100644 src/main/java/iuh/fit/se/ecommerce/config/CacheConfig.java create mode 100644 src/main/java/iuh/fit/se/ecommerce/config/NominatimCacheKeyGenerator.java create mode 100644 src/main/java/iuh/fit/se/ecommerce/controller/AddressController.java create mode 100644 src/main/java/iuh/fit/se/ecommerce/controller/GeocodingController.java create mode 100644 src/main/java/iuh/fit/se/ecommerce/dto/request/AddressRequest.java create mode 100644 src/main/java/iuh/fit/se/ecommerce/dto/response/AddressGeocodeResponse.java create mode 100644 src/main/java/iuh/fit/se/ecommerce/service/impl/AddressServiceImpl.java create mode 100644 src/main/java/iuh/fit/se/ecommerce/service/impl/NominatimServiceImpl.java create mode 100644 src/main/java/iuh/fit/se/ecommerce/service/interfaces/AddressService.java create mode 100644 src/main/java/iuh/fit/se/ecommerce/service/interfaces/NominatimService.java create mode 100644 src/main/resources/static/js/address-form.js create mode 100644 src/main/resources/static/js/map-picker.js create mode 100644 src/main/resources/static/js/search-results.js create mode 100644 src/main/resources/templates/search-results.html 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 a40e9ba..df63d78 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", @@ -94,6 +95,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 2d2ca92..ab0194f 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 @@ -328,7 +328,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 d5921a0..bafeaf2 100644 --- a/src/main/resources/static/fragments/header.html +++ b/src/main/resources/static/fragments/header.html @@ -84,23 +84,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 85ae099..68bf2a7 100644 --- a/src/main/resources/static/js/api.js +++ b/src/main/resources/static/js/api.js @@ -198,6 +198,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 516bea1..580c402 100644 --- a/src/main/resources/static/js/common.js +++ b/src/main/resources/static/js/common.js @@ -253,6 +253,9 @@ function initializeHeader() { badge.textContent = "0"; } } + + // Initialize search autocomplete + initSearchAutocomplete(); } function updateUserMenu() { @@ -459,13 +462,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/notifications.js b/src/main/resources/static/js/notifications.js index 2b82c23..3ab2e4c 100644 --- a/src/main/resources/static/js/notifications.js +++ b/src/main/resources/static/js/notifications.js @@ -16,14 +16,14 @@ function initializeNotifications() { try { if (!document.getElementById("notification-bell-container")) { const wrapper = document.createElement("div"); - wrapper.className = "notification-bell me-3"; + wrapper.className = "notification-bell-container me-3"; wrapper.id = "notification-bell-container"; wrapper.style.position = 'relative'; wrapper.innerHTML = ` - - - 0 + + + 0
@@ -147,12 +169,21 @@
Đổi mật khẩu
+ + + + + + + + +