diff --git a/backend/src/main/java/disscount/digitalCard/dao/DigitalCardRepository.java b/backend/src/main/java/disscount/digitalCard/dao/DigitalCardRepository.java index 17d15dc0..98d8bdd1 100644 --- a/backend/src/main/java/disscount/digitalCard/dao/DigitalCardRepository.java +++ b/backend/src/main/java/disscount/digitalCard/dao/DigitalCardRepository.java @@ -14,12 +14,9 @@ @Repository public interface DigitalCardRepository extends JpaRepository { - @Query("SELECT dc FROM DigitalCard dc WHERE dc.user = :user AND dc.deletedAt IS NULL") + @Query("SELECT dc FROM DigitalCard dc WHERE dc.user = :user AND dc.deletedAt IS NULL ORDER BY dc.updatedAt DESC") List findActiveByUser(User user); - @Query("SELECT dc FROM DigitalCard dc WHERE dc.id = :id AND dc.deletedAt IS NULL") - Optional findActiveById(UUID id); - @Query("SELECT dc FROM DigitalCard dc WHERE dc.id = :id AND dc.user = :user AND dc.deletedAt IS NULL") Optional findActiveByIdAndUser(UUID id, User user); } diff --git a/backend/src/main/java/disscount/digitalCard/domain/DigitalCard.java b/backend/src/main/java/disscount/digitalCard/domain/DigitalCard.java index be65cac2..0fae86ac 100644 --- a/backend/src/main/java/disscount/digitalCard/domain/DigitalCard.java +++ b/backend/src/main/java/disscount/digitalCard/domain/DigitalCard.java @@ -2,7 +2,6 @@ import disscount.util.Timestamps; import jakarta.persistence.*; -import jakarta.validation.constraints.NotBlank; import lombok.*; import java.time.LocalDateTime; @@ -27,36 +26,66 @@ public class DigitalCard { @JoinColumn(name = "user_id", nullable = false) private User user; - @NotBlank(message = "Title is required") - @Column(nullable = false) - private String title; + @Column(name = "card_name", nullable = false) + private String cardName; - @NotBlank(message = "Type is required") - @Column(name = "type") - private String type; + // Plain String, never @Enumerated: ddl-auto=update leaves a stale CHECK constraint + // behind if the vocabulary ever changes. Values: loyalty | gift | membership | other. + @Column(name = "card_type", nullable = false, length = 16) + private String cardType; - @NotBlank(message = "Value is required") - @Column(nullable = false) - private String value; + @Column(name = "store_name", nullable = false) + private String storeName; - @NotBlank(message = "Code type is required") - @Column(name = "code_type", nullable = false) + // cijene-api chain code when the user picked an official chain; null for free text. + @Column(name = "chain_code", length = 40) + private String chainCode; + + @Column(name = "code_value", nullable = false, length = 4096) + private String codeValue; + + // Barcode Detection API format name (ean_13, qr_code, ...) or "unknown". + @Column(name = "code_type", nullable = false, length = 32) private String codeType; - @Column - private String color; + @Column(name = "card_color", nullable = false, length = 7) + private String cardColor; + + @Column(name = "icon_image", columnDefinition = "TEXT") + private String iconImage; + + @Column(name = "front_image", columnDefinition = "TEXT") + private String frontImage; + + @Column(name = "back_image", columnDefinition = "TEXT") + private String backImage; - @Column(columnDefinition = "TEXT") + @Column(name = "note", length = 500) private String note; + // Null = not pinned, matching User's toggle timestamps. Pinning goes through + // @PreUpdate, so it counts as a change and bumps updatedAt. + @Column(name = "pinned_at") + private LocalDateTime pinnedAt; + @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + @Column(name = "deleted_at") private LocalDateTime deletedAt; @PrePersist protected void onCreate() { - createdAt = Timestamps.nowUtc(); + LocalDateTime now = Timestamps.nowUtc(); + createdAt = now; + updatedAt = now; + } + + @PreUpdate + protected void onUpdate() { + updatedAt = Timestamps.nowUtc(); } } diff --git a/backend/src/main/java/disscount/digitalCard/dto/DigitalCardDto.java b/backend/src/main/java/disscount/digitalCard/dto/DigitalCardDto.java index 7d4729f6..4e124706 100644 --- a/backend/src/main/java/disscount/digitalCard/dto/DigitalCardDto.java +++ b/backend/src/main/java/disscount/digitalCard/dto/DigitalCardDto.java @@ -1,25 +1,29 @@ package disscount.digitalCard.dto; -import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; -import lombok.NoArgsConstructor; import java.time.LocalDateTime; import java.util.UUID; @Data -@NoArgsConstructor -@AllArgsConstructor @Builder public class DigitalCardDto { - + private UUID id; - private String title; - private String value; - private String type; + private UUID userId; + private String cardName; + private String cardType; + private String storeName; + private String chainCode; + private String codeValue; private String codeType; - private String color; + private String cardColor; + private String iconImage; + private String frontImage; + private String backImage; private String note; + private LocalDateTime pinnedAt; private LocalDateTime createdAt; + private LocalDateTime updatedAt; } diff --git a/backend/src/main/java/disscount/digitalCard/dto/DigitalCardRequest.java b/backend/src/main/java/disscount/digitalCard/dto/DigitalCardRequest.java index ac620a82..a4aac9d4 100644 --- a/backend/src/main/java/disscount/digitalCard/dto/DigitalCardRequest.java +++ b/backend/src/main/java/disscount/digitalCard/dto/DigitalCardRequest.java @@ -1,23 +1,53 @@ package disscount.digitalCard.dto; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; import lombok.Data; @Data public class DigitalCardRequest { - @NotBlank(message = "Title is required") - private String title; + @NotBlank(message = "Naziv kartice je obavezan") + @Size(min = 2, max = 60, message = "Naziv kartice mora imati između 2 i 60 znakova") + private String cardName; - @NotBlank(message = "Value is required") - private String value; + @NotBlank(message = "Tip kartice je obavezan") + @Pattern(regexp = "loyalty|gift|membership|other", message = "Neispravan tip kartice") + private String cardType; - @NotBlank(message = "Type is required") - private String type; + @NotBlank(message = "Naziv trgovine je obavezan") + @Size(min = 2, max = 60, message = "Naziv trgovine mora imati između 2 i 60 znakova") + private String storeName; - @NotBlank(message = "Code type is required") + @Size(max = 40, message = "Neispravna oznaka trgovine") + private String chainCode; + + @NotBlank(message = "Kod kartice je obavezan") + @Size(max = 4096, message = "Kod kartice je predug") + private String codeValue; + + // Barcode Detection API vocabulary, deliberately not pattern-constrained beyond + // length: a browser adding a format should not need a backend release. + @NotBlank(message = "Tip koda je obavezan") + @Size(max = 32, message = "Neispravan tip koda") private String codeType; - private String color; + @NotBlank(message = "Boja kartice je obavezna") + @Pattern(regexp = "#[0-9a-fA-F]{6}", message = "Neispravna boja kartice") + private String cardColor; + + // Base64 data URIs. The client downscales the icon to 256px and the card faces to + // 1024px WebP, so these are abuse backstops rather than the real limits. + @Size(max = 400_000, message = "Ikona je prevelika") + private String iconImage; + + @Size(max = 1_200_000, message = "Slika prednje strane je prevelika") + private String frontImage; + + @Size(max = 1_200_000, message = "Slika stražnje strane je prevelika") + private String backImage; + + @Size(max = 500, message = "Bilješka može imati najviše 500 znakova") private String note; } diff --git a/backend/src/main/java/disscount/digitalCard/rest/DigitalCardController.java b/backend/src/main/java/disscount/digitalCard/rest/DigitalCardController.java index 903b50ef..e1a34489 100644 --- a/backend/src/main/java/disscount/digitalCard/rest/DigitalCardController.java +++ b/backend/src/main/java/disscount/digitalCard/rest/DigitalCardController.java @@ -13,13 +13,12 @@ import disscount.util.SecurityUtils; import java.util.List; -import java.util.Map; import java.util.UUID; @RestController @RequestMapping("/api/digital-cards") @RequiredArgsConstructor -@Tag(name = "Digital Cards", description = "Digital card management endpoints") +@Tag(name = "Digital Cards", description = "Loyalty card wallet endpoints") public class DigitalCardController { private final DigitalCardService digitalCardService; @@ -28,42 +27,47 @@ public class DigitalCardController { @PostMapping public ResponseEntity createCard(@Valid @RequestBody DigitalCardRequest request) { UUID userId = SecurityUtils.getCurrentUserId(); - DigitalCardDto card = digitalCardService.createCard(userId, request); - return ResponseEntity.ok(card); + DigitalCardDto created = digitalCardService.createCard(userId, request); + return ResponseEntity.ok(created); } - @Operation(summary = "Get all digital cards for current user") + @Operation(summary = "Get current user's digital cards") @GetMapping("/me") - public ResponseEntity> getUserCards() { + public ResponseEntity> getCurrentUserCards() { UUID userId = SecurityUtils.getCurrentUserId(); List cards = digitalCardService.getUserCards(userId); return ResponseEntity.ok(cards); } - @Operation(summary = "Get digital card by ID") - @GetMapping("/{id}") - public ResponseEntity getCardById(@PathVariable UUID id) { - UUID userId = SecurityUtils.getCurrentUserId(); - return digitalCardService.getCardById(id, userId) - .map(ResponseEntity::ok) - .orElse(ResponseEntity.notFound().build()); - } - @Operation(summary = "Update digital card") @PutMapping("/{id}") public ResponseEntity updateCard( @PathVariable UUID id, - @RequestBody DigitalCardRequest request) { + @Valid @RequestBody DigitalCardRequest request) { UUID userId = SecurityUtils.getCurrentUserId(); - DigitalCardDto updatedCard = digitalCardService.updateCard(id, userId, request); - return ResponseEntity.ok(updatedCard); + DigitalCardDto updated = digitalCardService.updateCard(id, userId, request); + return ResponseEntity.ok(updated); } - @Operation(summary = "Delete digital card (soft delete)") + @Operation(summary = "Delete digital card") @DeleteMapping("/{id}") - public ResponseEntity> deleteCard(@PathVariable UUID id) { + public ResponseEntity deleteCard(@PathVariable UUID id) { UUID userId = SecurityUtils.getCurrentUserId(); digitalCardService.deleteCard(id, userId); - return ResponseEntity.ok(Map.of("message", "Card deleted successfully")); + return ResponseEntity.noContent().build(); + } + + @Operation(summary = "Pin digital card") + @PatchMapping("/{id}/pin") + public ResponseEntity pinCard(@PathVariable UUID id) { + UUID userId = SecurityUtils.getCurrentUserId(); + return ResponseEntity.ok(digitalCardService.setPinned(id, userId, true)); + } + + @Operation(summary = "Unpin digital card") + @PatchMapping("/{id}/unpin") + public ResponseEntity unpinCard(@PathVariable UUID id) { + UUID userId = SecurityUtils.getCurrentUserId(); + return ResponseEntity.ok(digitalCardService.setPinned(id, userId, false)); } } diff --git a/backend/src/main/java/disscount/digitalCard/service/DigitalCardService.java b/backend/src/main/java/disscount/digitalCard/service/DigitalCardService.java index 5e70c94f..f542f06d 100644 --- a/backend/src/main/java/disscount/digitalCard/service/DigitalCardService.java +++ b/backend/src/main/java/disscount/digitalCard/service/DigitalCardService.java @@ -11,11 +11,12 @@ import disscount.digitalCard.dto.DigitalCardRequest; import disscount.exceptions.BadRequestException; import disscount.exceptions.UnauthorizedException; +import disscount.storeName.service.StoreNameNormalizer; +import disscount.storeName.service.StoreNameSuggestionService; import disscount.user.dao.UserRepository; import disscount.user.domain.User; import java.util.List; -import java.util.Optional; import java.util.UUID; @Service @@ -25,28 +26,35 @@ public class DigitalCardService { private final DigitalCardRepository digitalCardRepository; private final UserRepository userRepository; + private final StoreNameSuggestionService storeNameSuggestionService; public DigitalCardDto createCard(UUID userId, DigitalCardRequest request) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new UnauthorizedException("User not found")); + User user = requireUser(userId); DigitalCard card = DigitalCard.builder() .user(user) - .title(request.getTitle()) - .value(request.getValue()) - .type(request.getType()) + .cardName(request.getCardName()) + .cardType(request.getCardType()) + .storeName(request.getStoreName()) + .chainCode(request.getChainCode()) + .codeValue(request.getCodeValue()) .codeType(request.getCodeType()) - .color(request.getColor()) + .cardColor(request.getCardColor()) + .iconImage(request.getIconImage()) + .frontImage(request.getFrontImage()) + .backImage(request.getBackImage()) .note(request.getNote()) .build(); card = digitalCardRepository.save(card); + recordStoreNameIfCustom(request.getChainCode(), request.getStoreName()); + return convertToDto(card); } + @Transactional(readOnly = true) public List getUserCards(UUID userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new UnauthorizedException("User not found")); + User user = requireUser(userId); return digitalCardRepository.findActiveByUser(user) .stream() @@ -54,54 +62,95 @@ public List getUserCards(UUID userId) { .toList(); } - public Optional getCardById(UUID cardId, UUID userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new UnauthorizedException("User not found")); - - return digitalCardRepository.findActiveByIdAndUser(cardId, user) - .map(this::convertToDto); - } - public DigitalCardDto updateCard(UUID cardId, UUID userId, DigitalCardRequest request) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new UnauthorizedException("User not found")); + User user = requireUser(userId); + DigitalCard card = requireCard(cardId, user); - DigitalCard card = digitalCardRepository.findActiveByIdAndUser(cardId, user) - .orElseThrow(() -> new BadRequestException("Card not found")); + String previousStoreName = card.getStoreName(); - // Replace full resource (PUT semantics) - card.setTitle(request.getTitle()); - card.setValue(request.getValue()); - card.setType(request.getType()); + card.setCardName(request.getCardName()); + card.setCardType(request.getCardType()); + card.setStoreName(request.getStoreName()); + card.setChainCode(request.getChainCode()); + card.setCodeValue(request.getCodeValue()); card.setCodeType(request.getCodeType()); - card.setColor(request.getColor()); + card.setCardColor(request.getCardColor()); + card.setIconImage(request.getIconImage()); + card.setFrontImage(request.getFrontImage()); + card.setBackImage(request.getBackImage()); card.setNote(request.getNote()); card = digitalCardRepository.save(card); + + // Only a genuinely new name counts, so re-saving a card does not inflate its + // suggestion's usage count. + boolean nameChanged = !StoreNameNormalizer.normalize(previousStoreName) + .equals(StoreNameNormalizer.normalize(request.getStoreName())); + if (nameChanged) { + recordStoreNameIfCustom(request.getChainCode(), request.getStoreName()); + } + return convertToDto(card); } public void deleteCard(UUID cardId, UUID userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new UnauthorizedException("User not found")); - - DigitalCard card = digitalCardRepository.findActiveByIdAndUser(cardId, user) - .orElseThrow(() -> new BadRequestException("Card not found")); + User user = requireUser(userId); + DigitalCard card = requireCard(cardId, user); card.setDeletedAt(Timestamps.nowUtc()); digitalCardRepository.save(card); } + public DigitalCardDto setPinned(UUID cardId, UUID userId, boolean pinned) { + User user = requireUser(userId); + DigitalCard card = requireCard(cardId, user); + + card.setPinnedAt(pinned ? Timestamps.nowUtc() : null); + card = digitalCardRepository.save(card); + + return convertToDto(card); + } + + private User requireUser(UUID userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new UnauthorizedException("User not found")); + } + + /** Ownership is enforced by the query itself, so there is no separate check to forget. */ + private DigitalCard requireCard(UUID cardId, User user) { + return digitalCardRepository.findActiveByIdAndUser(cardId, user) + .orElseThrow(() -> new BadRequestException("Digital card not found")); + } + + // Official chains already have their own autocomplete group, so only free text is + // worth offering back to other users. + private void recordStoreNameIfCustom(String chainCode, String storeName) { + if (chainCode == null || chainCode.isBlank()) { + storeNameSuggestionService.record(storeName); + } + } + + // Kept private rather than extracted into a @Component mapper the way shopping lists + // did: that split exists so an owner view and a shared view cannot drift on what they + // expose, and a card has exactly one viewer. Extract it the day cards gain a second. private DigitalCardDto convertToDto(DigitalCard card) { return DigitalCardDto.builder() .id(card.getId()) - .title(card.getTitle()) - .value(card.getValue()) - .type(card.getType()) + .userId(card.getUser().getId()) + .cardName(card.getCardName()) + .cardType(card.getCardType()) + .storeName(card.getStoreName()) + .chainCode(card.getChainCode()) + .codeValue(card.getCodeValue()) .codeType(card.getCodeType()) - .color(card.getColor()) + .cardColor(card.getCardColor()) + .iconImage(card.getIconImage()) + .frontImage(card.getFrontImage()) + .backImage(card.getBackImage()) .note(card.getNote()) + .pinnedAt(card.getPinnedAt()) .createdAt(card.getCreatedAt()) + .updatedAt(card.getUpdatedAt()) .build(); } } diff --git a/backend/src/main/java/disscount/storeName/dao/StoreNameSuggestionRepository.java b/backend/src/main/java/disscount/storeName/dao/StoreNameSuggestionRepository.java new file mode 100644 index 00000000..8fbe9759 --- /dev/null +++ b/backend/src/main/java/disscount/storeName/dao/StoreNameSuggestionRepository.java @@ -0,0 +1,21 @@ +package disscount.storeName.dao; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import disscount.storeName.domain.StoreNameSuggestion; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface StoreNameSuggestionRepository extends JpaRepository { + + Optional findByNormalizedName(String normalizedName); + + @Query("SELECT s FROM StoreNameSuggestion s WHERE s.hiddenAt IS NULL AND s.usageCount >= :minUsage ORDER BY s.usageCount DESC, s.name ASC") + List findVisible(int minUsage, Pageable pageable); +} diff --git a/backend/src/main/java/disscount/storeName/domain/StoreNameSuggestion.java b/backend/src/main/java/disscount/storeName/domain/StoreNameSuggestion.java new file mode 100644 index 00000000..5b654709 --- /dev/null +++ b/backend/src/main/java/disscount/storeName/domain/StoreNameSuggestion.java @@ -0,0 +1,67 @@ +package disscount.storeName.domain; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.UUID; + +import disscount.util.Timestamps; + +/** + * Community store-name vocabulary, offered to everyone in the card form's autocomplete. + * Deliberately carries no user reference of any kind: suggestions are public, so + * attribution must not be recoverable from this table. + */ +@Entity +@Table(name = "store_name_suggestion") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class StoreNameSuggestion { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + // The display form as first submitted, trimmed and whitespace-collapsed. + @Column(name = "name", nullable = false, length = 60) + private String name; + + // Dedupe key: lowercased, diacritics stripped. See StoreNameNormalizer. + @Column(name = "normalized_name", nullable = false, unique = true, length = 60) + private String normalizedName; + + // Submission count, not a live card count: monotonic, never decremented on delete. + // Used only to order the suggestion list. + @Column(name = "usage_count", nullable = false) + @Builder.Default + private Integer usageCount = 0; + + // Moderation hide, reversible. Not deletedAt: the row must survive so its + // normalized_name slot stays taken and the next card save does not recreate it. + // TODO(store-name-moderation): rename and merge add a nullable merged_into_id UUID + // plus admin PATCH endpoints guarded by userService.requireAdmin() inside the service. + @Column(name = "hidden_at") + private LocalDateTime hiddenAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + LocalDateTime now = Timestamps.nowUtc(); + createdAt = now; + updatedAt = now; + } + + @PreUpdate + protected void onUpdate() { + updatedAt = Timestamps.nowUtc(); + } +} diff --git a/backend/src/main/java/disscount/storeName/dto/StoreNameSuggestionDto.java b/backend/src/main/java/disscount/storeName/dto/StoreNameSuggestionDto.java new file mode 100644 index 00000000..8fec673b --- /dev/null +++ b/backend/src/main/java/disscount/storeName/dto/StoreNameSuggestionDto.java @@ -0,0 +1,16 @@ +package disscount.storeName.dto; + +import lombok.Builder; +import lombok.Data; + +/** + * No id is exposed: a client cannot act on a single suggestion, and the name is already + * unique, so it serves as a stable key. + */ +@Data +@Builder +public class StoreNameSuggestionDto { + + private String name; + private Integer usageCount; +} diff --git a/backend/src/main/java/disscount/storeName/rest/StoreNameController.java b/backend/src/main/java/disscount/storeName/rest/StoreNameController.java new file mode 100644 index 00000000..0f33b284 --- /dev/null +++ b/backend/src/main/java/disscount/storeName/rest/StoreNameController.java @@ -0,0 +1,31 @@ +package disscount.storeName.rest; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import disscount.storeName.dto.StoreNameSuggestionDto; +import disscount.storeName.service.StoreNameSuggestionService; + +import java.util.List; + +@RestController +@RequestMapping("/api/store-names") +@RequiredArgsConstructor +@Tag(name = "Store Names", description = "Community store name suggestions") +public class StoreNameController { + + private final StoreNameSuggestionService storeNameSuggestionService; + + // The same list for everyone, so no current-user lookup: SecurityConfig's + // anyRequest().authenticated() is the only gate this needs. + @Operation(summary = "List community store name suggestions") + @GetMapping + public ResponseEntity> getStoreNames() { + return ResponseEntity.ok(storeNameSuggestionService.listVisible()); + } +} diff --git a/backend/src/main/java/disscount/storeName/service/StoreNameNormalizer.java b/backend/src/main/java/disscount/storeName/service/StoreNameNormalizer.java new file mode 100644 index 00000000..6d5cf0b4 --- /dev/null +++ b/backend/src/main/java/disscount/storeName/service/StoreNameNormalizer.java @@ -0,0 +1,38 @@ +package disscount.storeName.service; + +import java.text.Normalizer; +import java.util.Locale; + +/** + * Dedupe key for community store names. Mirrors normalizeForSearch in the frontend's + * utils/strings.ts, so "Müller", "muller" and "MULLER " collapse to one suggestion. + */ +public final class StoreNameNormalizer { + + private static final Locale CROATIAN = Locale.forLanguageTag("hr"); + + private StoreNameNormalizer() { + } + + public static String normalize(String raw) { + if (raw == null) { + return ""; + } + + String collapsed = raw.trim().replaceAll("\\s+", " "); + + // NFD splits accents into combining marks that \p{M} then strips, but it leaves + // the Croatian đ alone, so that pair is mapped by hand. + String stripped = Normalizer.normalize(collapsed, Normalizer.Form.NFD) + .replaceAll("\\p{M}", "") + .replace("đ", "d") + .replace("Đ", "D"); + + return stripped.toLowerCase(CROATIAN); + } + + /** The display form stored alongside the key: trimmed, inner whitespace collapsed. */ + public static String toDisplayForm(String raw) { + return raw == null ? "" : raw.trim().replaceAll("\\s+", " "); + } +} diff --git a/backend/src/main/java/disscount/storeName/service/StoreNameSuggestionService.java b/backend/src/main/java/disscount/storeName/service/StoreNameSuggestionService.java new file mode 100644 index 00000000..ebff9ba5 --- /dev/null +++ b/backend/src/main/java/disscount/storeName/service/StoreNameSuggestionService.java @@ -0,0 +1,76 @@ +package disscount.storeName.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import disscount.storeName.dao.StoreNameSuggestionRepository; +import disscount.storeName.domain.StoreNameSuggestion; +import disscount.storeName.dto.StoreNameSuggestionDto; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class StoreNameSuggestionService { + + // TODO(store-names): raise to 2 once there is enough volume, so a single typo does + // not reach everyone. 1 at launch, otherwise the suggestion group starts empty. + private static final int MIN_PUBLIC_USAGE = 1; + + private static final int MAX_SUGGESTIONS = 200; + + private static final int MAX_NAME_LENGTH = 60; + + private final StoreNameSuggestionRepository storeNameSuggestionRepository; + + /** + * Best-effort: recording a suggestion must never fail the card save that triggered it. + * REQUIRES_NEW is load-bearing, not stylistic. A concurrent insert on the unique + * normalized_name marks the *current* transaction rollback-only even when the + * exception is caught, so without its own transaction this would poison the caller. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void record(String rawName) { + String normalized = StoreNameNormalizer.normalize(rawName); + if (normalized.isBlank() || normalized.length() > MAX_NAME_LENGTH) { + return; + } + + try { + StoreNameSuggestion suggestion = storeNameSuggestionRepository + .findByNormalizedName(normalized) + .orElse(null); + + if (suggestion == null) { + storeNameSuggestionRepository.save(StoreNameSuggestion.builder() + .name(StoreNameNormalizer.toDisplayForm(rawName)) + .normalizedName(normalized) + .usageCount(1) + .build()); + return; + } + + suggestion.setUsageCount(suggestion.getUsageCount() + 1); + storeNameSuggestionRepository.save(suggestion); + } catch (Exception e) { + log.warn("Could not record store name suggestion '{}'", normalized, e); + } + } + + @Transactional(readOnly = true) + public List listVisible() { + return storeNameSuggestionRepository + .findVisible(MIN_PUBLIC_USAGE, PageRequest.of(0, MAX_SUGGESTIONS)) + .stream() + .map(suggestion -> StoreNameSuggestionDto.builder() + .name(suggestion.getName()) + .usageCount(suggestion.getUsageCount()) + .build()) + .toList(); + } +} diff --git a/docs/DIGITAL-CARDS.md b/docs/DIGITAL-CARDS.md new file mode 100644 index 00000000..a40b4fe9 --- /dev/null +++ b/docs/DIGITAL-CARDS.md @@ -0,0 +1,324 @@ +# Disscount: Digital Cards (Digitalne kartice) + +The loyalty-card wallet. A user saves the plastic cards they already carry, and the app renders the barcode or QR on demand so the card can be scanned straight off the phone at the till. + +Two things shape every decision in here: + +1. **It is used in a shop.** Often in a basement with no signal, at a till, with someone waiting. So it works offline, it opens fast, and the code panel is built for a scanner rather than for looks. +2. **A card number is a secret.** It never reaches localStorage, never reaches a URL, and never reaches a search parameter. + +## Table of contents + +1. [Quick reference](#1-quick-reference) +2. [How a card flows through the system](#2-how-a-card-flows-through-the-system) +3. [The data model](#3-the-data-model) +4. [Codes: scanning in, drawing out](#4-codes-scanning-in-drawing-out) +5. [Stores: official chains and community names](#5-stores-official-chains-and-community-names) +6. [Colour and contrast](#6-colour-and-contrast) +7. [Images](#7-images) +8. [Offline](#8-offline) +9. [Key files](#9-key-files) +10. [Libraries](#10-libraries) +11. [What is automatic vs manual](#11-what-is-automatic-vs-manual) +12. [Gotchas and lessons learned](#12-gotchas-and-lessons-learned) +13. [Future improvements and TODOs](#13-future-improvements-and-todos) + +--- + +## 1. Quick reference + +| Thing | Value | +| ----------------- | --------------------------------------------------------------- | +| Route | `/digital-cards`, protected, in `PROTECTED_ROUTE_PREFIXES` | +| Backend base path | `/api/digital-cards`, plus `/api/store-names` | +| Query key root | `digitalCards`, persisted offline. Suggestions use `storeNames` | +| Modals | `?modal=digital-card/new`, `/edit&id=`, `/view&id=` | +| Barcode renderer | `@bwip-js/browser`, synchronous, SVG | +| Scanner preset | `all`, unlike products which lock to EAN | +| Bottom nav | Fifth cell, long press opens a new card | +| PWA shortcut | Second of three on Android, after Skeniraj | + +--- + +## 2. How a card flows through the system + +```mermaid +flowchart TD + A[User taps Dodaj karticu] --> B[digital-card-modal] + B --> C{How is the code entered?} + C -->|Scanner icon| D[useCameraScanner preset all] + C -->|Typed| E[codeValue field] + D --> F[rawValue + format] + F --> G[toCodeType maps format to our vocabulary] + E --> H + G --> H[code-preview draws it live] + H --> I{bwip-js can encode it?} + I -->|Yes| J[SVG preview] + I -->|No| K[Large-print value + warning] + B --> L[Submit] + L --> M[Modal closes immediately] + M --> N{Online?} + N -->|Yes| O[POST /api/digital-cards] + N -->|No| P[Mutation pauses in the offline queue] + P -->|Reconnect| O + O --> Q[Card appears in the grid] + Q --> R[Tap a tile] + R --> S[View modal: big code, wake lock on] +``` + +The submit path is worth understanding because it is not the obvious one. The modal **closes before the request finishes**. If the write then fails, the error is parked in `modal-error-bus`, the modal reopens, and the error is applied to the form. This is the same pattern shopping lists use, and it is why the wallet feels instant. + +--- + +## 3. The data model + +Backend entity `disscount.digitalCard.domain.DigitalCard`, table `digital_card`. + +| Column | Type | Notes | +| --------------------------- | ---------------------- | -------------------------------------------------------------------------- | +| `id` | UUID | | +| `user_id` | UUID FK | Lazy `@ManyToOne` to `app_user` | +| `card_name` | varchar | What the user calls it | +| `card_type` | varchar(16) | `loyalty` / `gift` / `membership` / `other`. A plain String, never an enum | +| `store_name` | varchar | Free text, always set | +| `chain_code` | varchar(40), nullable | Set only when an official chain was picked | +| `code_value` | varchar(4096) | The card number | +| `code_type` | varchar(32) | Barcode Detection API format name, or `unknown` | +| `card_color` | varchar(7) | `#rrggbb` | +| `icon_image` | TEXT, nullable | base64 data URI | +| `front_image` | TEXT, nullable | base64 data URI | +| `back_image` | TEXT, nullable | base64 data URI | +| `note` | varchar(500), nullable | | +| `pinned_at` | timestamp, nullable | Null means not pinned | +| `created_at` / `updated_at` | timestamp | Stamped through `Timestamps.nowUtc()` | +| `deleted_at` | timestamp, nullable | Soft delete | + +**Why `card_type` is a String and not an enum.** The backend runs `ddl-auto=update`, which never drops anything. Turning a column into a Java enum makes Hibernate add a CHECK constraint; changing the vocabulary later leaves the old constraint behind, and every write that uses a new value fails with a 500. Same reasoning applies to `code_type`, which additionally must accept whatever a browser's Barcode Detection API decides to return, without needing a backend release. + +**Why `pinned_at` is a timestamp and not a boolean.** It matches the rest of the codebase (`User`'s notification toggles, `ContactMessage.readAt`), and it records _when_, which a boolean throws away. + +**Ownership.** Every id-addressed method goes through `findActiveByIdAndUser(cardId, user)`, so the ownership check is the query itself and cannot be forgotten in a service method. Do not copy `NotificationController`, which omits it. + +--- + +## 4. Codes: scanning in, drawing out + +### The vocabulary + +`codeType` stores the **Barcode Detection API's own format names**: `ean_13`, `qr_code`, `code_128`, and so on. This is deliberate. The scanner already hands back exactly these strings, so a scan needs no translation on the way in. + +There is one extra value, `unknown`, labelled **"Samo broj/tekst"** in the UI. It covers two cases: a membership number that has no barcode at all, and a value the chosen symbology cannot encode. Both render as large high-contrast text instead. + +### Scanning + +`useCameraScanner().openScanner({ preset: "all", onScan })` from `context/scanner-context.tsx`. The `all` preset accepts QR, Aztec, Codabar, Code 39/93/128, Data Matrix, ITF and PDF417 on top of the retail EAN/UPC formats. Products lock to `product` (EAN only); a loyalty card can carry anything. + +`onScan` fills `codeValue` **and** `codeType` in one go, then the preview redraws. + +### Drawing + +`utils/generate-code-svg.ts` wraps `@bwip-js/browser`. + +```ts +generateCodeSvg(codeValue, codeType); +// -> { ok: true, svg } | { ok: false, reason: "unsupported" | "invalid" } +``` + +It **never throws**. bwip-js rejects a value that does not fit its symbology (an EAN-13 needs twelve digits plus a checksum), and that is a normal thing for a user to do, not an exception. The caller renders the plain value instead. + +Two details worth keeping: + +- `includetext: false`. The human-readable line is our own HTML, which keeps user input out of the SVG string that gets injected with `dangerouslySetInnerHTML`. +- `CODE_TYPE_TO_BCID` maps our stored names to bwip-js encoder names. They are not the same: `itf` becomes `interleaved2of5`, `aztec` becomes `azteccode`, and `codabar` becomes `rationalizedCodabar`. + +--- + +## 5. Stores: official chains and community names + +A card carries **both** `storeName` (free text, always) and `chainCode` (nullable). Picking an official chain sets both; typing free text clears `chainCode`. + +The autocomplete has two groups: + +| Group | Source | +| ----------------- | ---------------------------------------------------------------------- | +| Službene trgovine | `cijeneService.useGetChainStats()`, labelled through `getChainLabel()` | +| Ostalo | `GET /api/store-names`, the community suggestions | + +### Community store names + +When a user saves a card with free text and no chain code, that name is recorded in `store_name_suggestion` and offered to everyone else. The aim is to stop the same corner shop accumulating under five spellings. + +The table **carries no user reference of any kind**. The list is public, so attribution must not be recoverable from it. + +`normalized_name` is the dedupe key: lowercased, diacritics stripped, `đ` mapped to `d`, via `StoreNameNormalizer`, mirroring the frontend's `normalizeForSearch`. + +`usage_count` is a **monotonic submission count**, not a live count of cards using the name. It is never decremented on delete. It only orders the list, and a live count would need decrements, orphan cleanup and a backfill for no visible gain. + +--- + +## 6. Colour and contrast + +Every card has a colour, and the colour backs text, so contrast is a correctness problem rather than a styling one. + +Colours are generated at one fixed saturation and lightness, so the wallet reads as one designed set. **That alone does not make white text legible.** Yellow and green are far brighter than blue at the same lightness: hue 55 lands near `#aea229`, which is about 2.7:1 against white, well under the 4.5:1 minimum. Several curated chain brand colours are just as light. + +Two rules fix it together: + +1. **`foregroundFor(hex)`** picks white or a dark ink per card, whichever wins on contrast. This covers the freeform hue slider and the brand colour map with one rule. +2. **`hexForHue(hue)`** darkens a hue until one of the two inks clears 4.5:1. A band around cyan sits in a dead zone where neither ink passes at the base lightness; those hues step down a couple of points. Worst case across all 360 hues is 4.52:1. + +The colour is suggested, in this order, and only until the user touches the picker: + +1. The chain's brand colour, from `CHAIN_BRAND_COLORS`. +2. The dominant colour of an uploaded icon or front image, from `extractDominantColor`. + +A manual pick wins permanently. Resetting the form restores the suggestions. + +--- + +## 7. Images + +Three optional base64 images: `iconImage` (256px), `frontImage` and `backImage` (1024px). The card faces are the backup when a generated code will not scan, so the cashier can read the number or barcode off the photo. + +All three go through **`resizeImageToWebp`**, the same compressor the settings avatar uses. Nothing here rolls its own. EXIF orientation is handled for free, because `createImageBitmap` applies it by default, which matters when the photo came from a phone held in portrait. + +Images live **outside react-hook-form** in `use-card-images.ts`, exactly like the settings avatar field, for two reasons: a base64 image would blow the localStorage draft quota, and it does not belong in dirty tracking. The modal merges them back in on submit and folds their dirty flag into the shell's indicator. + +The colour sampler receives the **compressed output**, not the original file. Passing the original meant decoding a possibly 12-megapixel photo a second time, right after the compressor had already decoded it. + +--- + +## 8. Offline + +Offline is the whole point, so both halves are wired. + +**Reads.** `digitalCards` and `storeNames` are in `PERSISTED_QUERY_KEY_PREFIXES` in `lib/offline/cached-query-keys.ts`, so the wallet and the autocomplete survive a reload with no signal. + +**Writes.** Four mutations are registered in `lib/offline/offline-mutations.ts` so a reload can replay them: create, update, delete and pin. Each needs four things kept in sync, and missing one fails quietly: + +1. A key in `OFFLINE_MUTATION_KEYS`. +2. That key as `mutationKey` on the hook. +3. A `defineOfflineMutation` registration with the raw query function. +4. The raw query function exported from `lib/api/digital-cards`. + +Create, update and pin also pass an `onError` handler. A reload loses the `onError` given at `mutate()` time, and those three have no modal left to show an error in, so without it a failed replay reverted in silence. Delete does not pass one, because its only caller already toasts. + +There is **no `GET /api/digital-cards/{id}`**. The view and edit modals select the card out of the `["digitalCards", "me"]` cache through `useDigitalCard(id)`. This is what makes a deep link work offline: a by-id fetch would have no cache entry to fall back on. + +--- + +## 9. Key files + +### Frontend, feature + +| Path | Role | +| ------------------------------------------------------- | --------------------------------------------------------- | +| `app/(user)/digital-cards/page.tsx` | Server component, reads `?q=` | +| `components/digital-cards-client.tsx` | Auth gate, search, sort, the three body states | +| `components/digital-card-tile.tsx` | The 3:2 tile, flip container, overlay open button | +| `components/card-face-front.tsx` / `card-face-back.tsx` | The two faces | +| `components/card-code.tsx` | Renders the SVG or the large-print fallback | +| `components/card-icon.tsx` | Icon fallback chain: image, chain logo, initials, generic | +| `components/forms/digital-card-modal.tsx` | Create and edit, drafts, retry, colour suggestions | +| `components/forms/store-name-field.tsx` | The grouped autocomplete | +| `components/forms/code-field.tsx` | Input plus the scanner button | +| `components/forms/color-picker/` | Three variants behind a temporary switcher | +| `components/view/digital-card-view-modal.tsx` | The till view, holds the wake lock | +| `components/view/card-code-panel.tsx` | Forced-white high-contrast code panel | +| `utils/card-colors.ts` | Palette, brand map, contrast helpers | +| `utils/generate-code-svg.ts` | The bwip-js boundary | +| `utils/card-sorting.ts` | Six comparators plus the pinned split | +| `hooks/use-card-images.ts` | The three images, outside RHF | + +### Frontend, shared + +| Path | Role | +| ----------------------------------------------------- | --------------------------------------------------- | +| `constants/card-codes.ts` | `CARD_TYPES`, `CODE_TYPES`, `SCANNABLE_CODE_TYPES` | +| `lib/api/digital-cards/{keys,queries,hooks,index}.ts` | Service layer | +| `lib/api/store-names/` | Suggestion service | +| `lib/modal/modal-retry-bus.ts` | In-memory stash for values that must not touch disk | +| `hooks/use-wake-lock.ts` | Screen wake lock, shared | +| `utils/browser/extract-dominant-color.ts` | Dominant hue of an image | +| `utils/browser/image.ts` | `resizeImageToWebp`, shared with the avatar | + +### Backend + +| Path | Role | +| ------------------------------------------------------- | -------------------------------------------------- | +| `disscount/digitalCard/domain/DigitalCard.java` | Entity | +| `disscount/digitalCard/dao/DigitalCardRepository.java` | `findActiveBy...` JPQL | +| `disscount/digitalCard/service/DigitalCardService.java` | CRUD, pin, suggestion recording | +| `disscount/digitalCard/rest/DigitalCardController.java` | `/api/digital-cards` | +| `disscount/storeName/` | Suggestion entity, normalizer, service, controller | + +--- + +## 10. Libraries + +| Library | Version | Used for | +| -------------------------- | ---------------- | -------------------------------------------- | +| `@bwip-js/browser` | `^4.11.2` | Drawing barcodes and QR as SVG | +| `@yudiel/react-qr-scanner` | `^2.6.0` | The camera scanner | +| `barcode-detector` | `^3.2.1` | Format names and the still-image decode path | +| `react-hook-form` | `^7.68.0` | The card form | +| `zod` | `^4.1.13` | Request, form and DTO schemas | +| `@tanstack/react-query` | `^5.90.12` | Caching and the offline mutation queue | +| `motion` | `^12.23.26` | The staggered grid reveal | +| Spring Boot | `3.1.0`, Java 21 | Backend | + +No colour picker library. The picker is swatches plus a styled range input, roughly forty lines, and adding one would have been more code than writing it. + +--- + +## 11. What is automatic vs manual + +| Concern | Automatic | Manual | +| ---------------------- | ---------------------------------------------- | -------------------------------------------------- | +| Chain list | Pulled from the price API and cached six hours | Adding a logo PNG to `public/store-chains/` | +| Chain brand colour | Suggested on pick | Curating `CHAIN_BRAND_COLORS` by hand | +| Card colour legibility | `foregroundFor` picks the ink per card | Nothing | +| Code type after a scan | Filled from the detected format | Correcting it if the scanner guessed wrong | +| Offline reads | Persisted by the key allowlist | Adding a new query root to that allowlist | +| Offline writes | Replayed on reconnect | The four-step registration per mutation | +| Community store names | Recorded on save, offered to everyone | Moderation, which does not exist yet | +| PWA shortcut | Emitted from the nav item | Generating the icon PNGs and picking the top three | +| Schema changes | `ddl-auto=update` adds columns | Dropping or renaming anything, by hand, in SQL | + +--- + +## 12. Gotchas and lessons learned + +**bwip-js does not tree shake.** The docs say per-symbology imports keep the bundle small. They do not: BWIPP is one generated blob, so importing thirteen encoders costs the same as importing all hundred. Measured at 844 KB raw, 210 KB gzipped, confirmed by finding unimported symbologies in the build output. Webpack does scope it to the `/digital-cards` route chunk, so no other page pays for it. `generateCodeSvg` is deliberately synchronous, because every tile draws a code and a lazy import would only move the same download behind a loading state. + +**Codabar's export is `rationalizedCodabar`.** And the `bcid` option must be the bwip-js encoder name, not the scanner's format string. Both were wrong first time and only the compiler caught the first one. + +**Module-scope constants have a dead zone.** `CARD_SWATCHES` calls `hexForHue` while the module is still evaluating, and `hexForHue` now needs the ink constants. A `const` declared below would be read in its temporal dead zone and throw at import time, taking the whole page with it. Contrast helpers therefore sit **above** `hexForHue` in `card-colors.ts`, and moving them will break the page rather than fail a test. + +**The entity outlet stays mounted between openings.** So a modal reached twice with different ids reuses the instance. The card form's draft merge bails out while the form is dirty, which meant editing card A, closing, then opening card B showed A's edits under B's title. Both card modals are keyed by id in `entity-modal-outlet.tsx`. Add-to-list and watchlist carry the same fix for the same reason. + +**A card code must never reach disk, but must survive a retry.** These pull in opposite directions. The rule that reconciles them: never on disk, fine in memory until the submit settles. `codeValue` is in `useFormDraft`'s `exclude`, and a failed optimistic save stashes it in `modal-retry-bus`, an in-memory sibling of the error bus that dies with the tab. Drafts written by older builds are deleted outright rather than sanitised, because their shape came from a build we no longer have. + +**A card code must never reach a URL either.** The page search filters on `cardName`, `storeName` and `note` only. Adding `codeValue` would put card numbers into `?q=`, into history, and into anything that reads either. + +**`ddl-auto=update` never drops anything.** Production still held the pre-rework `digital_card` table, whose columns did not match. It had to be dropped by hand before deploying, or the new schema would have merged into the stale columns and the new `NOT NULL` columns would have failed against existing rows. + +**Recording a store name must not poison the card save.** `StoreNameSuggestionService.record()` runs inside the card-save transaction. A concurrent insert colliding on the unique `normalized_name` marks that transaction rollback-only **even when the exception is caught**, so the card save would fail for an unrelated reason. `@Transactional(propagation = REQUIRES_NEW)` gives it its own transaction, and it swallows and logs. It is called from a different bean, which is what makes the proxy apply the annotation at all: a self-invocation would silently skip it. + +**The specular sheen is not decoration.** A flat coloured div does not read as a card. The diagonal band, the light-catching top edge and the raised icon medallion are what sell it at tile size, and the band softens on light cards where it would otherwise read as glare. + +**Reduced motion has to gate the transform, not just the transition.** Gating only the transition leaves a reduced-motion user with an instant snap to the flipped state, which is worse than no flip. Tailwind's `hover:` variant already wraps in `@media (hover: hover)`, so a tap never triggers the flip on touch. + +--- + +## 13. Future improvements and TODOs + +- [ ] **Pick one colour picker.** Three variants ship behind a temporary switcher (`color-picker-switcher.tsx`, labelled privremeno) so they can be compared in the running app. Deleting the two losers and the switcher is a follow-up. +- [ ] **Migrate to the newer data-fetching conventions.** Digital cards is the last domain still on `useGetCurrentUserDigitalCards` rather than `queryOptions()` descriptors, `useAuthedQuery` and `AsyncSection`, and it has no colocated skeletons or `loading.tsx`. Blocked on the loading-system PR landing. +- [ ] **Moderate community store names.** The entity already carries `hidden_at` for it. Admin needs to list, hide, restore, rename and merge, following `AdminContactController`, with `requireAdmin` called inside the service rather than the controller. A `merged_into_id` column comes with it. +- [ ] **Raise `MIN_PUBLIC_USAGE` to 2.** It is 1 so the suggestion group is not empty at launch. Two is better for typo hygiene once there is volume. +- [ ] **Onboarding v2 should nudge saving a card**, seeded from the chains the user pins in preferences. Suggest, never require. TODO sits in `onboarding-steps.ts`. +- [ ] **Consider scrubbing card codes from Sentry.** Share tokens already have `scrub-share-token.ts` because they are secrets; a card code is a secret in the same way. Needs a look at whether one can actually reach a breadcrumb first. +- [ ] **Export and import cards**, so a phone change does not mean retyping the wallet. +- [ ] **Loyalty prices.** The long game: if the chains ever expose them, a card links a user to the prices that only apply with it, which then reaches the product card, the shopping list total and the best-store calculation. +- [ ] **`GET /api/digital-cards/{id}`** if a card ever needs to be reachable by someone who is not its owner. Today the absence is a feature, not a gap. diff --git a/docs/LANDING.md b/docs/LANDING.md index 6b113def..afb9155a 100644 --- a/docs/LANDING.md +++ b/docs/LANDING.md @@ -267,7 +267,7 @@ The exact chain count is never hardcoded. The number of covered retail chains gr ## Future improvements and TODOs -- Wire the remaining coming-soon feature cards (Analiza potrošnje, Digitalne kartice, Karta trgovina) once their pages ship, by uncommenting the `href` in `features.ts` and dropping `comingSoon`. +- Wire the remaining coming-soon feature cards (Analiza potrošnje, Karta trgovina) once their pages ship, by uncommenting the `href` in `features.ts` and dropping `comingSoon`. - Move `ScrollReveal` and `StaggerChildren` out of `components/ui/` (AGENTS.md reserves that folder for unedited shadcn primitives) into `components/custom/` (e.g. an `animation/` folder) with default exports, matching the convention for hand-written components. - Consider an FAQ-driven long-tail SEO expansion and a real testimonials/social-proof section once there is content for it. - The landing is Croatian-only; if the app adds `next-intl`, the landing copy in the `data/*` files is the natural first surface to translate. diff --git a/docs/MOBILE-NAV.md b/docs/MOBILE-NAV.md index 053bec00..3153de65 100644 --- a/docs/MOBILE-NAV.md +++ b/docs/MOBILE-NAV.md @@ -126,7 +126,7 @@ A locked cell is a `disabled` button in `text-muted-foreground/70`, so it takes Two things worth knowing: - This runs **against** Apple's Human Interface Guidelines, which are emphatic that a tab must never be disabled (in iOS 26 `UITabBarItem.isEnabled` has no effect at all). Consistency with the app's other two navigations won, since a bar that walks you into a teaser page the header refuses to open is the more confusing inconsistency. -- The admin escape exists so `/digital-cards`, which is a working page carrying the badge only until barcode rendering lands, stays reachable from a phone. +- The admin escape was there so `/digital-cards` stayed reachable while it carried the coming-soon badge. The page has since shipped and dropped the flag, so the escape no longer applies to it. The same rule now applies to `HeaderNavItem`, which previously blocked coming-soon items for admins too. Note that the escape is unreachable there today: `HeaderNav` swaps the whole list for a single dashboard link whenever `canAccessDashboard` is true, which covers every admin. It is there for consistency and for whenever that layout changes. @@ -445,14 +445,14 @@ A sheet can also decline the field: `ProductsSheet` withholds `initialFocusRef` Long press is strictly an **accelerator**, never the only way to reach something. Every target is a `?modal=` URL that a visible, tappable control also reaches, which is what keeps it keyboard and screen-reader accessible. -| Gesture | On `/products/` | Everywhere else | Enabled? | -| ------------------------ | -------------------------- | -------------------------- | ------------------------------------------------------- | -| Hold **Karta** | store preferences | store preferences | admins only, until the map drops `comingSoon` | -| Hold **Praćenje** | `?modal=watchlist&ean=…` | nothing | yes | -| Hold the **centre cell** | the barcode scanner | the barcode scanner | yes | -| Hold **Popisi** | `?modal=add-to-list&ean=…` | `?modal=shopping-list/new` | yes | -| Hold **Kartice** | `?modal=digital-card/new` | `?modal=digital-card/new` | wired, off until digital cards ship, cell locked anyway | -| Hold a **product card** | the quick-actions sheet | the quick-actions sheet | yes | +| Gesture | On `/products/` | Everywhere else | Enabled? | +| ------------------------ | -------------------------- | -------------------------- | --------------------------------------------- | +| Hold **Karta** | store preferences | store preferences | admins only, until the map drops `comingSoon` | +| Hold **Praćenje** | `?modal=watchlist&ean=…` | nothing | yes | +| Hold the **centre cell** | the barcode scanner | the barcode scanner | yes | +| Hold **Popisi** | `?modal=add-to-list&ean=…` | `?modal=shopping-list/new` | yes | +| Hold **Kartice** | `?modal=digital-card/new` | `?modal=digital-card/new` | live | +| Hold a **product card** | the quick-actions sheet | the quick-actions sheet | yes | ### Targets that depend on the route diff --git a/docs/PWA.md b/docs/PWA.md index f677d118..edba6536 100644 --- a/docs/PWA.md +++ b/docs/PWA.md @@ -184,7 +184,7 @@ The masked one has to stay full bleed. Chrome hands a maskable icon to Android a **Skeniraj has no route of its own.** The scanner is imperative (`context/scanner-context.tsx`), so the shortcut points at `/?scan=1` and `components/custom/pwa/scan-shortcut.tsx` picks the flag up, strips it from the URL with `replaceState` before opening the camera (so a refresh or a back navigation does not reopen it), and routes the scanned code through `useProductNavigation`. -Adding Karta and Digitalne kartice once they ship is tracked in [#127](https://github.com/OffCrazyFreak/Disscount/issues/127), which is really a "pick the final three" decision rather than an append. +Digitalne kartice has since shipped and taken the second slot, so Android now shows Skeniraj, Kartice and Popisi, with Praćenje fourth and desktop only. The order is stated explicitly in `constants/pwa-shortcuts.ts` rather than inherited from `navigation.ts`, where a reshuffle would silently drop a shortcut off phones. Karta is still open in [#127](https://github.com/OffCrazyFreak/Disscount/issues/127). ### The install UX (`src/components/custom/pwa/`) @@ -266,7 +266,7 @@ flowchart LR - `react-query-provider.tsx` uses `PersistQueryClientProvider`. The `QueryClient` sets a default `gcTime` equal to the persister `maxAge` (7 days), so entries are not garbage-collected out of memory before they can be restored from disk. - `persister.ts` builds a `createAsyncStoragePersister` backed by `idb-keyval` (IndexedDB, larger and safer than localStorage). `maxAge` 7 days, `buster` `"3"`. The history: `"2"` was the `ShoppingListDto` reshape for sharing, where a restored pre-change list had no `myAccess` and every capability check would have read that as no access; `"3"` was the move to a per-identity key, which orphaned the old shared blob. **Bumping it is heavier than it looks:** a mismatch makes `persistQueryClient` call `removeClient()`, which discards the whole persisted client, queued offline writes included, under every key rather than only the changed one. The token-to-id sharing move deliberately did not bump it and retired its two mutation keys with tombstone defaults instead. - `cache-identity.ts` scopes the IndexedDB entry to the signed-in account (`disscount-react-query-cache:`, or `:anon`). Before this there was one browser-wide blob shared by every account on a device, with a destructive purge as the only defence, which is why a shared list could not be persisted at all. The identity is mirrored in localStorage because the persister has to pick a key synchronously at boot, before the session resolves. -- `cached-query-keys.ts` is the **whitelist**: only successful queries whose top-level key is in `cijene`, `shoppingLists`, `shoppingListItems`, `watchlist`, `digitalCards`, `pinnedStores`, `pinnedPlaces`, or `users` are persisted. Keys under `cijene/prices` have a narrower allowlist: only `cijene/prices/product` is persisted, while every other `cijene/prices/*` key is excluded. Bulk product-list results under `cijene/prices/search`, for example, stay in the in-memory React Query cache and the bounded service-worker cache instead of accumulating in IndexedDB as searches and facets change. Canonical single-product price keys remain persisted for product-detail offline reads. Everything else (for example admin data) is never written to disk. Coming-soon features carry `TODO(offline)` markers here to be added when they ship. +- `cached-query-keys.ts` is the **whitelist**: only successful queries whose top-level key is in `cijene`, `shoppingLists`, `shoppingListItems`, `watchlist`, `digitalCards`, `storeNames`, `pinnedStores`, `pinnedPlaces`, or `users` are persisted. Keys under `cijene/prices` have a narrower allowlist: only `cijene/prices/product` is persisted, while every other `cijene/prices/*` key is excluded. Bulk product-list results under `cijene/prices/search`, for example, stay in the in-memory React Query cache and the bounded service-worker cache instead of accumulating in IndexedDB as searches and facets change. Canonical single-product price keys remain persisted for product-detail offline reads. Everything else (for example admin data) is never written to disk. Coming-soon features carry `TODO(offline)` markers here to be added when they ship. - `purge.ts` (`purgeOfflineCache`) removes user-specific queries from the in-memory cache, wipes the persisted snapshot, and deletes the `shopping-list-pages`, `cijene-api` and `others` service worker buckets, matched by exact name rather than by substring so a bucket like `pages-rsc` is never caught by accident, guarded so a failed clear never blocks logout. Public `cijene` queries remain in memory and are persisted again on the next successful save, while in-flight cancellation is scoped to user-specific queries so logging out cannot abort a public price fetch. `user-context.tsx` calls it on **any change of identity**, which covers explicit logout, session expiry, a revoked cookie, sign-out in another tab, and one account replacing another. It deliberately does **not** fire merely because there is no session: that condition is true on every page load for a visitor who never logs in, and firing there wiped their queued offline writes on the next boot with no error and nothing left to replay. ### 5c. Offline UX @@ -471,7 +471,7 @@ Read from `frontend/package.json`. - [ ] **App Badging API** (`navigator.setAppBadge`) for unread notifications on the installed icon. - [ ] **Service-worker "update available" prompt** (a toast when a new worker is waiting), which also fixes the "hard-refresh after deploy" gotcha in [`DEPLOYMENT.md`](DEPLOYMENT.md). -- [ ] **Screen Wake Lock + max brightness** while showing a loyalty-card barcode (for the digital-cards rewrite). +- [x] **Screen Wake Lock** while showing a loyalty-card barcode, in `hooks/use-wake-lock.ts`, held by the card view modal. There is no web API for brightness, so the code panel forces its own white background instead. - [ ] **Web Share** (outgoing) and **Share Target** (incoming) for products and lists. - [ ] **Native Barcode Detection** as a fast path on Android, keeping `@yudiel/react-qr-scanner` as the universal fallback. - [ ] **Real manifest screenshots** to replace the branded placeholder cards. diff --git a/docs/README.md b/docs/README.md index af9b569c..d49c7c7c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,4 +11,5 @@ Reference documentation for Disscount. Each file is a standalone deep-dive into - [SEARCH.md](SEARCH.md) - the shared matcher, diacritic folding, ranking, Croatian collation, and why there is no search library. - [LANDING.md](LANDING.md) - landing page composition, server-vs-client rendering, SEO, fonts. - [BRAND.md](BRAND.md) - brand image system (logo, favicon, PWA icons, splash screens, social kit). +- [DIGITAL-CARDS.md](DIGITAL-CARDS.md) - the loyalty card wallet: code scanning and drawing, store autocomplete, colour contrast, offline. - [SUPPORT.md](SUPPORT.md) - Ko-fi support flow, GitHub funding links, and future recognition rules. diff --git a/docs/SEARCH.md b/docs/SEARCH.md index 652088f5..fce3dc6d 100644 --- a/docs/SEARCH.md +++ b/docs/SEARCH.md @@ -121,7 +121,7 @@ Calling `localeCompare` with no locale collates them as plain `c`, `s`, `z`, whi | Add to shopping list | `app/products/components/forms/shopping-list-selector.tsx` | list title via `keywords` | | Updates / blog | `app/updates/page.tsx` | title, excerpt, content | | Suggestions | `app/suggestions/components/suggestions-client.tsx` | suggestion fields | -| Digital cards | `app/(user)/digital-cards/components/digital-cards-client.tsx` | title, type, note | +| Digital cards | `app/(user)/digital-cards/components/digital-cards-client.tsx` | cardName, storeName, note | | Shopping lists index | `app/(user)/shopping-lists/components/shopping-lists-client.tsx` | title | | Watchlist | `app/(user)/watchlist/hooks/use-watchlist-data.ts` | product name, brand | | Watchlist suggestions | `app/(user)/watchlist/hooks/use-watchlist-suggestions.ts` | product name, brand | diff --git a/docs/STATE-PERSISTENCE.md b/docs/STATE-PERSISTENCE.md index bbcf3b51..fe5250ba 100644 --- a/docs/STATE-PERSISTENCE.md +++ b/docs/STATE-PERSISTENCE.md @@ -125,7 +125,7 @@ Forms wired to drafts: | Watchlist item modal | `watchlist-item-modal.tsx` | restore handled by the hook; one number per watch mode, `watchType` excluded | | Add to shopping list | `use-add-to-list-form.ts` | restore handled by the hook | | Shopping list create/edit | `shopping-list-modal.tsx` | prefill-then-merge (`restore: false`) | -| Digital card create/edit | `digital-card-modal.tsx` | prefill-then-merge; also feeds scan-to-fill | +| Digital card create/edit | `digital-card-modal.tsx` | prefill-then-merge; `codeValue` excluded, retried through `modal-retry-bus` | | Settings and onboarding | `settings-modal-host.tsx` | one shared draft, cleared after a successful save and onboarding completion | | Contact | `contact-modal.tsx` | prefill from profile, then merge draft on top | @@ -261,7 +261,7 @@ The URL and localStorage layers use only browser-native APIs; there is no extra - **New-entity modals auto-restore; edit modals merge the draft themselves.** The shopping-list and digital-card modals pass `restore: !isEdit`: a brand-new list or card is rehydrated by the hook (its own effect re-runs when the draft key changes), while an edit modal loads its base record first and merges the draft on top with `restore: false` (draft wins). The contact modal is prefill-then-merge (`restore: false`). Letting the hook auto-restore an edit modal would double-reset and fight the prefill. -- **Never draft passwords, base64 images, or card codes.** Pass them in `exclude`. Passwords must not touch disk, a base64 avatar would blow the localStorage quota, and the digital-card code (`value`) is excluded so a card number never persists. The avatar field lives outside forms and drafts entirely for this reason. +- **Never draft passwords, base64 images, or card codes.** Pass them in `exclude`. Passwords must not touch disk, a base64 avatar would blow the localStorage quota, and the digital-card code (`codeValue`) is excluded so a card number never persists. Because that also means a failed save would come back without it, the in-flight code is stashed in `lib/modal/modal-retry-bus.ts`, an in-memory sibling of the error bus that dies with the tab: never on disk, fine in memory until the submit settles. The avatar field lives outside forms and drafts entirely for this reason. - **Old drafts are type-guarded on restore.** If a field's type changed since a draft was written (for example a number where the field is now a string), the restore skips it so a stale draft cannot poison validation. Keys the form no longer has at all are skipped too, so renaming or splitting a field cannot strand a dead entry for the rest of the TTL. @@ -279,7 +279,7 @@ The URL and localStorage layers use only browser-native APIs; there is no extra - **localStorage preferences are per-device and are NOT purged on logout.** The IndexedDB data cache and the scoped service worker buckets are wiped when the identity changes. Preferences like view mode or the install-banner snooze are intentionally device-level and survive a logout. -- **`viewModes` is wired but never written yet.** `useViewMode` returns `[mode, setMode]`, and both consumers (`products-client.tsx`, `digital-cards-client.tsx`) destructure the mode alone, because `ViewSwitcher` is parked behind [issue #61](https://github.com/OffCrazyFreak/Disscount/issues/61). So the key exists in the storage shape and in the hook, but nothing writes it and every list renders its default. Unparking the switcher means taking the setter at both call sites. Read the hook's own comment before changing it: the storage read is deliberately deferred to an effect, because `getViewMode` returns the default when there is no `window`, so seeding state from it directly would be a hydration mismatch. +- **`viewModes` is wired but never written yet.** `useViewMode` returns `[mode, setMode]`, and its one consumer (`products-client.tsx`) destructures the mode alone, because `ViewSwitcher` is parked behind [issue #61](https://github.com/OffCrazyFreak/Disscount/issues/61). So the key exists in the storage shape and in the hook, but nothing writes it and every list renders its default. Unparking the switcher means taking the setter at both call sites. Read the hook's own comment before changing it: the storage read is deliberately deferred to an effect, because `getViewMode` returns the default when there is no `window`, so seeding state from it directly would be a hydration mismatch. --- diff --git a/frontend/next.config.ts b/frontend/next.config.ts index e1b7f18d..4d1d3790 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -87,6 +87,13 @@ const nextConfig: NextConfig = { { key: "Referrer-Policy", value: "no-referrer" }, ], }, + { + // Same reasoning as the lists above, for a route that holds loyalty card numbers + // and so has even less business in an index. No Referrer-Policy override here: + // the card id in the URL grants nothing on its own, unlike a shared list id. + source: "/digital-cards/:path*", + headers: [{ key: "X-Robots-Tag", value: "noindex, nofollow" }], + }, ]; }, }; diff --git a/frontend/package.json b/frontend/package.json index ad9b15a6..45d1539e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "email": "email dev --dir src/emails --port 3366" }, "dependencies": { + "@bwip-js/browser": "^4.11.2", "@hookform/resolvers": "^5.2.2", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 34e9c23a..50365ffe 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -15,6 +15,9 @@ importers: .: dependencies: + '@bwip-js/browser': + specifier: ^4.11.2 + version: 4.11.2 '@hookform/resolvers': specifier: ^5.2.2 version: 5.4.0(react-hook-form@7.81.0(react@19.2.8)) @@ -396,6 +399,9 @@ packages: '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + '@bwip-js/browser@4.11.2': + resolution: {integrity: sha512-+6wZZY218c0Q6e9xjpESAs851ezWp8pZp2vS7WSjwX0GBOeGoT04NbvgHWPvtg4NIWXEIxoOgSBPnhmkFXhyNg==} + '@clack/core@0.3.5': resolution: {integrity: sha512-5cfhQNH+1VQ2xLQlmzXMqUoiaH0lRBq9/CLW9lTyMbuKLC3+xEK01tHVvyut++mLOn5urSHmkm6I0Lg9MaJSTQ==} @@ -5783,6 +5789,8 @@ snapshots: '@borewit/text-codec@0.2.2': {} + '@bwip-js/browser@4.11.2': {} + '@clack/core@0.3.5': dependencies: picocolors: 1.1.1 diff --git a/frontend/public/brand/shortcuts/digital-cards-any.png b/frontend/public/brand/shortcuts/digital-cards-any.png new file mode 100644 index 00000000..fd3d5562 Binary files /dev/null and b/frontend/public/brand/shortcuts/digital-cards-any.png differ diff --git a/frontend/public/brand/shortcuts/digital-cards.png b/frontend/public/brand/shortcuts/digital-cards.png new file mode 100644 index 00000000..166f3c15 Binary files /dev/null and b/frontend/public/brand/shortcuts/digital-cards.png differ diff --git a/frontend/scripts/generate-shortcut-icons.mjs b/frontend/scripts/generate-shortcut-icons.mjs index 8b5f0902..3f2e6ce4 100644 --- a/frontend/scripts/generate-shortcut-icons.mjs +++ b/frontend/scripts/generate-shortcut-icons.mjs @@ -8,7 +8,7 @@ import { mkdir } from "node:fs/promises"; import path from "node:path"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { Eye, ListChecks, ScanBarcode } from "lucide-react"; +import { CreditCard, Eye, ListChecks, ScanBarcode } from "lucide-react"; import { GREEN } from "./lib/brand.mjs"; import { ROOT } from "./lib/cart-source.mjs"; import { SRGB } from "./lib/srgb.mjs"; @@ -28,6 +28,7 @@ const RADIUS = Math.round(SIZE * (96 / 512)); // Keyed by navigation item id, since manifest.ts derives each src from it. const SHORTCUTS = { scan: ScanBarcode, + "digital-cards": CreditCard, "shopping-lists": ListChecks, watchlist: Eye, }; diff --git a/frontend/src/app/(root)/data/features.ts b/frontend/src/app/(root)/data/features.ts index b4ccc750..2d71d0fd 100644 --- a/frontend/src/app/(root)/data/features.ts +++ b/frontend/src/app/(root)/data/features.ts @@ -83,8 +83,7 @@ export const featureItems: IFeatureItem[] = [ description: "Sve kartice trgovina u mobitelu - novčanik konačno na dijeti.", icon: CreditCard, - comingSoon: true, - // href: "/digital-cards", + href: "/digital-cards", }, { title: "Bez interneta", diff --git a/frontend/src/app/(user)/digital-cards/components/card-code.tsx b/frontend/src/app/(user)/digital-cards/components/card-code.tsx new file mode 100644 index 00000000..84f10109 --- /dev/null +++ b/frontend/src/app/(user)/digital-cards/components/card-code.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useMemo } from "react"; + +import { cn } from "@/lib/utils"; +import type { CodeType } from "@/constants/card-codes"; +import { generateCodeSvg } from "@/app/(user)/digital-cards/utils/generate-code-svg"; + +interface ICardCodeProps { + codeValue: string; + codeType: CodeType; + /** Shows the value under the code, which the checkout view wants and a tile does not. */ + showValue?: boolean; + className?: string; +} + +/** + * Renders the barcode or QR, or the plain value when the symbology cannot encode it. The + * fallback is a feature, not an error path: a membership number with no barcode is still a + * usable card, and the cashier can key it in. + */ +export default function CardCode({ + codeValue, + codeType, + showValue = false, + className, +}: ICardCodeProps) { + const result = useMemo( + () => generateCodeSvg(codeValue, codeType), + [codeValue, codeType], + ); + + return ( +
+ {result.ok ? ( +
+ ) : ( +

+ {codeValue} +

+ )} + + {showValue && result.ok && ( +

+ {codeValue} +

+ )} +
+ ); +} diff --git a/frontend/src/app/(user)/digital-cards/components/card-face-back.tsx b/frontend/src/app/(user)/digital-cards/components/card-face-back.tsx new file mode 100644 index 00000000..25833e4e --- /dev/null +++ b/frontend/src/app/(user)/digital-cards/components/card-face-back.tsx @@ -0,0 +1,41 @@ +"use client"; + +import CardCode from "@/app/(user)/digital-cards/components/card-code"; +import type { DigitalCardDto } from "@/lib/api/types"; + +interface ICardFaceBackProps { + card: DigitalCardDto; +} + +/** + * Decorative quick access for a mouse, so it is hidden from assistive tech: the code's + * real home is the detail modal, which every input method can reach. + * + * The white face is literal rather than a theme token: a scanner reads contrast, so a + * dark-mode card back would not scan. + */ +export default function CardFaceBack({ card }: ICardFaceBackProps) { + return ( +