From 49474033520d07adfa6fe54966793886ff6933e0 Mon Sep 17 00:00:00 2001 From: CrazyFreak <44674613+OffCrazyFreak@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:52:58 +0200 Subject: [PATCH 01/14] chore(deps): Add bwip-js for barcode generation Changes: - Add @bwip-js/browser ^4.11.2 as a direct dependency The digital cards rework renders loyalty barcodes and QR codes in the browser, and nothing in the tree could generate them: barcode-detector only reads. Takes a range rather than an exact pin, since it has no security or data blast radius. --- frontend/package.json | 1 + frontend/pnpm-lock.yaml | 8 ++++++++ 2 files changed, 9 insertions(+) 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 From 2e96207fcd2d68e68c112fe06d924f7954109d93 Mon Sep 17 00:00:00 2001 From: CrazyFreak <44674613+OffCrazyFreak@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:53:10 +0200 Subject: [PATCH 02/14] feat(digital-cards): Add community store name suggestions Changes: - Add disscount.storeName package: entity, repository, DTO, normalizer, service, controller - Expose GET /api/store-names returning visible suggestions ordered by usage Free text store names users type on a card become suggestions for everyone else, so the same shop does not accumulate under five spellings. The table carries no user reference of any kind: the list is public, so attribution must not be recoverable from it. Notes: - record() is REQUIRES_NEW deliberately. It is called from inside the card save transaction, and a concurrent insert colliding on the unique normalized_name would otherwise mark that transaction rollback only even when the exception is caught, failing the save. It swallows and logs instead, being best effort. - hiddenAt rather than deletedAt: a hidden suggestion must keep occupying its normalized_name slot, or the next card save recreates it. The read filter ships now even though moderation does not, so adding it later cannot change what offline clients already see. - usageCount is a monotonic submission count, never decremented, used only for ordering. A live card count would need decrements, orphan cleanup and a backfill. - MIN_PUBLIC_USAGE is 1 so the group is not empty at launch. TODO to raise it to 2 once there is volume, which is better for typo hygiene. - Admin moderation (hide, restore, rename, merge) is a TODO on the entity, naming the columns and the requireAdmin-inside-the-service pattern it will need. --- .../dao/StoreNameSuggestionRepository.java | 21 +++++ .../storeName/domain/StoreNameSuggestion.java | 65 ++++++++++++++++ .../storeName/dto/StoreNameSuggestionDto.java | 16 ++++ .../storeName/rest/StoreNameController.java | 31 ++++++++ .../service/StoreNameNormalizer.java | 38 +++++++++ .../service/StoreNameSuggestionService.java | 77 +++++++++++++++++++ 6 files changed, 248 insertions(+) create mode 100644 backend/src/main/java/disscount/storeName/dao/StoreNameSuggestionRepository.java create mode 100644 backend/src/main/java/disscount/storeName/domain/StoreNameSuggestion.java create mode 100644 backend/src/main/java/disscount/storeName/dto/StoreNameSuggestionDto.java create mode 100644 backend/src/main/java/disscount/storeName/rest/StoreNameController.java create mode 100644 backend/src/main/java/disscount/storeName/service/StoreNameNormalizer.java create mode 100644 backend/src/main/java/disscount/storeName/service/StoreNameSuggestionService.java 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..2ece6347 --- /dev/null +++ b/backend/src/main/java/disscount/storeName/domain/StoreNameSuggestion.java @@ -0,0 +1,65 @@ +package disscount.storeName.domain; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * 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 = LocalDateTime.now(); + createdAt = now; + updatedAt = now; + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} 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..d723d07e --- /dev/null +++ b/backend/src/main/java/disscount/storeName/service/StoreNameSuggestionService.java @@ -0,0 +1,77 @@ +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; +import java.util.stream.Collectors; + +@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()) + .collect(Collectors.toList()); + } +} From 89c9d84a80d01c8634507461cedf6662f00d24ec Mon Sep 17 00:00:00 2001 From: CrazyFreak <44674613+OffCrazyFreak@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:53:22 +0200 Subject: [PATCH 03/14] feat(digital-cards): Rebuild the digital card backend Changes: - Replace the disscount.digitalCard package: entity, repository, DTOs, service, controller - Add store, colour, three base64 images, pinning and updatedAt to the card - Expose POST, GET /me, PUT, DELETE plus PATCH /{id}/pin and /{id}/unpin - Record a custom store name as a suggestion on create, and on update only when it changed The previous entity carried a title, a free text type and a code, which was too thin to build a wallet on: no store link, no images, no pinning, no updatedAt. Notes: - cardType and codeType are plain String columns, never @Enumerated. ddl-auto=update leaves a stale CHECK constraint behind when a vocabulary changes, which then 500s on write. - chainCode is a nullable opaque string beside a free text storeName, mirroring PinnedStore's storeApiId and storeName denormalisation, so a card stays readable with no join and a shop outside the official list still works. - Pin is its own verb rather than a PUT, which is a full replace and would ship all three base64 images back just to set a timestamp. - Every id addressed method resolves through findActiveByIdAndUser, so ownership cannot be forgotten. Deliberately unlike NotificationController, which omits it. - Images are TEXT columns with @Size caps on the request DTO, following User.image. Three at cap is roughly a 2.8 MB body and no server side ceiling is configured, so the client pre-checks the encoded length before sending. - Read verified only: Maven must not run here, so please compile before pushing. --- .../dao/DigitalCardRepository.java | 5 +- .../digitalCard/domain/DigitalCard.java | 61 ++++++--- .../digitalCard/dto/DigitalCardDto.java | 22 ++-- .../digitalCard/dto/DigitalCardRequest.java | 46 +++++-- .../rest/DigitalCardController.java | 46 +++---- .../service/DigitalCardService.java | 119 ++++++++++++------ 6 files changed, 205 insertions(+), 94 deletions(-) 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 b456db49..0b106952 100644 --- a/backend/src/main/java/disscount/digitalCard/domain/DigitalCard.java +++ b/backend/src/main/java/disscount/digitalCard/domain/DigitalCard.java @@ -1,7 +1,6 @@ package disscount.digitalCard.domain; import jakarta.persistence.*; -import jakarta.validation.constraints.NotBlank; import lombok.*; import java.time.LocalDateTime; @@ -26,36 +25,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 = LocalDateTime.now(); + LocalDateTime now = LocalDateTime.now(); + createdAt = now; + updatedAt = now; + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); } } 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 7245bb37..fc488ff5 100644 --- a/backend/src/main/java/disscount/digitalCard/service/DigitalCardService.java +++ b/backend/src/main/java/disscount/digitalCard/service/DigitalCardService.java @@ -10,13 +10,15 @@ 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.time.LocalDateTime; import java.util.List; -import java.util.Optional; import java.util.UUID; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -25,83 +27,128 @@ 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() .map(this::convertToDto) - .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); + .collect(Collectors.toList()); } 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(LocalDateTime.now()); digitalCardRepository.save(card); } + public DigitalCardDto setPinned(UUID cardId, UUID userId, boolean pinned) { + User user = requireUser(userId); + DigitalCard card = requireCard(cardId, user); + + card.setPinnedAt(pinned ? LocalDateTime.now() : 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); + } + } + 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(); } } From ee93e3a3918dfbaaf4a47f1008afbb271b0086c1 Mon Sep 17 00:00:00 2001 From: CrazyFreak <44674613+OffCrazyFreak@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:53:35 +0200 Subject: [PATCH 04/14] feat(digital-cards): Add the card API layer and offline wiring Changes: - Add the shared card and code vocabularies in constants/card-codes.ts - Add zod schemas for the card request, form and DTO, plus store name suggestions - Add lib/api/digital-cards and lib/api/store-names service triplets - Register both services in the api barrel - Persist the digitalCards and storeNames query roots for offline reads - Register create, update, delete and pin as replayable offline mutations Offline is the point of the feature: a shop with no signal is exactly where a loyalty card gets used, so reads are cached and writes queue and replay. Notes: - codeType stores the Barcode Detection API's own format names (ean_13, qr_code), which is precisely what the scanner returns, so a scan needs no translation on the way in and bwip-js encoder names stay a rendering detail. - The vocabulary adds "unknown" for a card whose number has no barcode, and it doubles as the graceful landing spot when a symbology rejects a value. - digitalCardFormSchema omits the three images: they live outside react-hook-form like the settings avatar, since base64 would blow the localStorage draft quota. - setDigitalCardPinned is one function over the two backend verbs, so the offline registry needs one key instead of two. - storeNames is cached too, otherwise the form's autocomplete is empty offline. --- frontend/src/constants/card-codes.ts | 29 +++++ frontend/src/lib/api/digital-cards/hooks.ts | 63 +++++++++++ frontend/src/lib/api/digital-cards/index.ts | 105 +----------------- frontend/src/lib/api/digital-cards/queries.ts | 46 ++++++++ frontend/src/lib/api/index.ts | 2 + frontend/src/lib/api/schemas/digital-card.ts | 62 +++++++---- frontend/src/lib/api/schemas/index.ts | 1 + frontend/src/lib/api/schemas/store-name.ts | 8 ++ frontend/src/lib/api/store-names/hooks.ts | 14 +++ frontend/src/lib/api/store-names/index.ts | 9 ++ frontend/src/lib/api/store-names/queries.ts | 10 ++ frontend/src/lib/offline/cached-query-keys.ts | 1 + .../src/lib/offline/offline-mutation-keys.ts | 4 + frontend/src/lib/offline/offline-mutations.ts | 34 ++++++ 14 files changed, 266 insertions(+), 122 deletions(-) create mode 100644 frontend/src/constants/card-codes.ts create mode 100644 frontend/src/lib/api/digital-cards/hooks.ts create mode 100644 frontend/src/lib/api/digital-cards/queries.ts create mode 100644 frontend/src/lib/api/schemas/store-name.ts create mode 100644 frontend/src/lib/api/store-names/hooks.ts create mode 100644 frontend/src/lib/api/store-names/index.ts create mode 100644 frontend/src/lib/api/store-names/queries.ts diff --git a/frontend/src/constants/card-codes.ts b/frontend/src/constants/card-codes.ts new file mode 100644 index 00000000..3c6cf17a --- /dev/null +++ b/frontend/src/constants/card-codes.ts @@ -0,0 +1,29 @@ +import type { BarcodeFormat } from "barcode-detector"; + +// Stored codeType vocabulary: the Barcode Detection API's own format names, which is +// exactly what the scanner hands back, so a scan needs no translation on the way in. +export const SCANNABLE_CODE_TYPES = [ + "ean_13", + "ean_8", + "upc_a", + "upc_e", + "code_128", + "code_39", + "code_93", + "itf", + "codabar", + "qr_code", + "data_matrix", + "pdf417", + "aztec", +] as const satisfies readonly BarcodeFormat[]; + +// "unknown" is the escape hatch for a code we cannot draw: a membership number, or a +// value the chosen symbology rejects. The card then prints the value large instead. +export const CODE_TYPES = [...SCANNABLE_CODE_TYPES, "unknown"] as const; + +export type CodeType = (typeof CODE_TYPES)[number]; + +export const CARD_TYPES = ["loyalty", "gift", "membership", "other"] as const; + +export type CardType = (typeof CARD_TYPES)[number]; diff --git a/frontend/src/lib/api/digital-cards/hooks.ts b/frontend/src/lib/api/digital-cards/hooks.ts new file mode 100644 index 00000000..cb18f632 --- /dev/null +++ b/frontend/src/lib/api/digital-cards/hooks.ts @@ -0,0 +1,63 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { OFFLINE_MUTATION_KEYS } from "@/lib/offline/offline-mutation-keys"; +import { DigitalCardRequest, DigitalCardDto } from "@/lib/api/types"; +import { + createDigitalCard, + getCurrentUserDigitalCards, + updateDigitalCard, + deleteDigitalCard, + setDigitalCardPinned, +} from "@/lib/api/digital-cards/queries"; + +const CARDS_KEY = ["digitalCards"]; + +export function useCreateDigitalCard() { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: OFFLINE_MUTATION_KEYS.digitalCardCreate, + mutationFn: createDigitalCard, + onSuccess: () => queryClient.invalidateQueries({ queryKey: CARDS_KEY }), + }); +} + +export function useGetCurrentUserDigitalCards({ + enabled = true, +}: { enabled?: boolean } = {}) { + return useQuery({ + queryKey: ["digitalCards", "me"], + queryFn: getCurrentUserDigitalCards, + enabled, + }); +} + +export function useUpdateDigitalCard() { + const queryClient = useQueryClient(); + return useMutation< + DigitalCardDto, + Error, + { id: string; data: DigitalCardRequest } + >({ + mutationKey: OFFLINE_MUTATION_KEYS.digitalCardUpdate, + mutationFn: ({ id, data }) => updateDigitalCard(id, data), + onSuccess: () => queryClient.invalidateQueries({ queryKey: CARDS_KEY }), + }); +} + +export function useDeleteDigitalCard() { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: OFFLINE_MUTATION_KEYS.digitalCardDelete, + mutationFn: deleteDigitalCard, + onSuccess: () => queryClient.invalidateQueries({ queryKey: CARDS_KEY }), + }); +} + +export function useSetDigitalCardPinned() { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: OFFLINE_MUTATION_KEYS.digitalCardSetPinned, + mutationFn: ({ id, pinned }) => setDigitalCardPinned(id, pinned), + onSuccess: () => queryClient.invalidateQueries({ queryKey: CARDS_KEY }), + }); +} diff --git a/frontend/src/lib/api/digital-cards/index.ts b/frontend/src/lib/api/digital-cards/index.ts index 22558d1f..b1d2a8c1 100644 --- a/frontend/src/lib/api/digital-cards/index.ts +++ b/frontend/src/lib/api/digital-cards/index.ts @@ -1,104 +1,9 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; -import apiClient from "@/lib/api/api-base"; -import { - DigitalCardRequest, - DigitalCardDto, - digitalCardRequestSchema, - digitalCardDtoSchema, -} from "@/lib/api/types"; +import * as queries from "@/lib/api/digital-cards/queries"; +import * as hooks from "@/lib/api/digital-cards/hooks"; -/** - * Create a new digital card - */ -export async function createDigitalCard( - data: DigitalCardRequest, -): Promise { - const validatedData = digitalCardRequestSchema.parse(data); +export * from "@/lib/api/digital-cards/queries"; +export * from "@/lib/api/digital-cards/hooks"; - const response = await apiClient.post( - "/api/digital-cards", - validatedData, - ); - - return digitalCardDtoSchema.parse(response.data); -} - -/** - * Get all digital cards for current user - */ -export async function getUserDigitalCards(): Promise { - const response = await apiClient.get( - "/api/digital-cards/me", - ); - - return response.data.map((item) => digitalCardDtoSchema.parse(item)); -} - -/** - * Update digital card - */ -export async function updateDigitalCard( - id: string, - data: DigitalCardRequest, -): Promise { - const validatedData = digitalCardRequestSchema.parse(data); - - const response = await apiClient.put( - `/api/digital-cards/${id}`, - validatedData, - ); - - return digitalCardDtoSchema.parse(response.data); -} - -/** - * Delete digital card - */ -export async function deleteDigitalCard(id: string): Promise { - await apiClient.delete(`/api/digital-cards/${id}`); -} - -// React Query hooks -export function useCreateDigitalCard() { - return useMutation({ - mutationFn: createDigitalCard, - }); -} - -export function useGetUserDigitalCards({ enabled = true } = {}) { - return useQuery({ - queryKey: ["digitalCards", "me"], - queryFn: getUserDigitalCards, - enabled, - }); -} - -export function useUpdateDigitalCard() { - return useMutation< - DigitalCardDto, - Error, - { id: string; data: DigitalCardRequest } - >({ - mutationFn: ({ id, data }) => updateDigitalCard(id, data), - }); -} - -export function useDeleteDigitalCard() { - return useMutation({ - mutationFn: deleteDigitalCard, - }); -} - -const digitalCardService = { - createDigitalCard, - getUserDigitalCards, - updateDigitalCard, - deleteDigitalCard, - // React Query hooks - useCreateDigitalCard, - useGetUserDigitalCards, - useUpdateDigitalCard, - useDeleteDigitalCard, -}; +const digitalCardService = { ...queries, ...hooks }; export default digitalCardService; diff --git a/frontend/src/lib/api/digital-cards/queries.ts b/frontend/src/lib/api/digital-cards/queries.ts new file mode 100644 index 00000000..1664d07c --- /dev/null +++ b/frontend/src/lib/api/digital-cards/queries.ts @@ -0,0 +1,46 @@ +import apiClient from "@/lib/api/api-base"; +import { DigitalCardRequest, DigitalCardDto } from "@/lib/api/types"; + +export async function createDigitalCard( + data: DigitalCardRequest, +): Promise { + const response = await apiClient.post( + "/api/digital-cards", + data, + ); + return response.data; +} + +export async function getCurrentUserDigitalCards(): Promise { + const response = await apiClient.get( + "/api/digital-cards/me", + ); + return response.data; +} + +export async function updateDigitalCard( + id: string, + data: DigitalCardRequest, +): Promise { + const response = await apiClient.put( + `/api/digital-cards/${id}`, + data, + ); + return response.data; +} + +export async function deleteDigitalCard(id: string): Promise { + await apiClient.delete(`/api/digital-cards/${id}`); +} + +// One function over two endpoints: pinning must not ship the card's images back, which +// a full PUT would, so the backend keeps them as separate verbs. +export async function setDigitalCardPinned( + id: string, + pinned: boolean, +): Promise { + const response = await apiClient.patch( + `/api/digital-cards/${id}/${pinned ? "pin" : "unpin"}`, + ); + return response.data; +} diff --git a/frontend/src/lib/api/index.ts b/frontend/src/lib/api/index.ts index 693bc2bb..a5b8501c 100644 --- a/frontend/src/lib/api/index.ts +++ b/frontend/src/lib/api/index.ts @@ -3,6 +3,7 @@ import userService from "@/lib/api/users"; import preferencesService from "@/lib/api/preferences"; import shoppingListService from "@/lib/api/shopping-lists"; import digitalCardService from "@/lib/api/digital-cards"; +import storeNameService from "@/lib/api/store-names"; import watchlistService from "@/lib/api/watchlist"; import adminService from "@/lib/api/admin"; import contactService from "@/lib/api/contact"; @@ -15,6 +16,7 @@ export { preferencesService, shoppingListService, digitalCardService, + storeNameService, watchlistService, adminService, contactService, diff --git a/frontend/src/lib/api/schemas/digital-card.ts b/frontend/src/lib/api/schemas/digital-card.ts index 9df4f58d..3195552e 100644 --- a/frontend/src/lib/api/schemas/digital-card.ts +++ b/frontend/src/lib/api/schemas/digital-card.ts @@ -1,41 +1,59 @@ import { z } from "zod"; -// Digital Card schemas +import { CARD_TYPES, CODE_TYPES } from "@/constants/card-codes"; + export const digitalCardRequestSchema = z.object({ - title: z + cardName: z .string() - .min(1, "Naziv je obavezan") - .max(100, "Naziv može imati najviše 100 znakova"), - value: z + .trim() + .min(2, "Naziv kartice mora imati najmanje 2 znaka") + .max(60, "Naziv kartice može imati najviše 60 znakova"), + cardType: z.enum(CARD_TYPES, { message: "Odaberi tip kartice" }), + storeName: z .string() - .min(1, "Vrijednost je obavezna") - .max(500, "Vrijednost može imati najviše 500 znakova"), - type: z + .trim() + .min(2, "Naziv trgovine mora imati najmanje 2 znaka") + .max(60, "Naziv trgovine može imati najviše 60 znakova"), + chainCode: z.string().max(40).nullable(), + codeValue: z .string() - .min(1, "Tip je obavezan") - .max(50, "Tip može imati najviše 50 znakova"), - codeType: z + .trim() + .min(1, "Unesi ili skeniraj kod kartice") + .max(4096, "Kod kartice je predug"), + codeType: z.enum(CODE_TYPES, { message: "Odaberi tip koda" }), + cardColor: z.string().regex(/^#[0-9a-fA-F]{6}$/, "Neispravna boja kartice"), + iconImage: z.string().max(400_000, "Ikona je prevelika").nullable(), + frontImage: z .string() - .min(1, "Tip koda je obavezan") - .max(50, "Tip koda može imati najviše 50 znakova"), - color: z + .max(1_200_000, "Slika prednje strane je prevelika") + .nullable(), + backImage: z .string() - .regex(/^#[0-9A-Fa-f]{6}$/, "Boja mora biti u HEX formatu (#ffffff)") - .nullable() - .optional(), + .max(1_200_000, "Slika stražnje strane je prevelika") + .nullable(), note: z .string() - .max(500, "Napomena može imati najviše 500 znakova") - .nullable() - .optional(), + .trim() + .max(500, "Bilješka može imati najviše 500 znakova") + .nullable(), +}); + +// The three images live outside react-hook-form (see use-card-images.ts), so the form +// validates a narrower shape and the submit hook merges them back in. +export const digitalCardFormSchema = digitalCardRequestSchema.omit({ + iconImage: true, + frontImage: true, + backImage: true, }); -// DTO schema extends request schema export const digitalCardDtoSchema = digitalCardRequestSchema.extend({ id: z.string(), + userId: z.string(), + pinnedAt: z.string().nullable(), createdAt: z.string(), + updatedAt: z.string(), }); -// Type exports export type DigitalCardRequest = z.infer; +export type DigitalCardFormData = z.infer; export type DigitalCardDto = z.infer; diff --git a/frontend/src/lib/api/schemas/index.ts b/frontend/src/lib/api/schemas/index.ts index 455fe9c8..11bb4854 100644 --- a/frontend/src/lib/api/schemas/index.ts +++ b/frontend/src/lib/api/schemas/index.ts @@ -5,5 +5,6 @@ export * from "@/lib/api/schemas/digital-card"; export * from "@/lib/api/schemas/shopping-list"; export * from "@/lib/api/schemas/shopping-list-item"; export * from "@/lib/api/schemas/preferences"; +export * from "@/lib/api/schemas/store-name"; export * from "@/lib/api/schemas/notifications"; export * from "@/lib/api/schemas/watchlist"; diff --git a/frontend/src/lib/api/schemas/store-name.ts b/frontend/src/lib/api/schemas/store-name.ts new file mode 100644 index 00000000..a9fc8c64 --- /dev/null +++ b/frontend/src/lib/api/schemas/store-name.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const storeNameSuggestionSchema = z.object({ + name: z.string(), + usageCount: z.number().int(), +}); + +export type StoreNameSuggestion = z.infer; diff --git a/frontend/src/lib/api/store-names/hooks.ts b/frontend/src/lib/api/store-names/hooks.ts new file mode 100644 index 00000000..ca437d2f --- /dev/null +++ b/frontend/src/lib/api/store-names/hooks.ts @@ -0,0 +1,14 @@ +import { useQuery } from "@tanstack/react-query"; + +import { StoreNameSuggestion } from "@/lib/api/types"; +import { getStoreNameSuggestions } from "@/lib/api/store-names/queries"; + +export function useGetStoreNameSuggestions({ + enabled = true, +}: { enabled?: boolean } = {}) { + return useQuery({ + queryKey: ["storeNames", "suggestions"], + queryFn: getStoreNameSuggestions, + enabled, + }); +} diff --git a/frontend/src/lib/api/store-names/index.ts b/frontend/src/lib/api/store-names/index.ts new file mode 100644 index 00000000..b29079a0 --- /dev/null +++ b/frontend/src/lib/api/store-names/index.ts @@ -0,0 +1,9 @@ +import * as queries from "@/lib/api/store-names/queries"; +import * as hooks from "@/lib/api/store-names/hooks"; + +export * from "@/lib/api/store-names/queries"; +export * from "@/lib/api/store-names/hooks"; + +const storeNameService = { ...queries, ...hooks }; + +export default storeNameService; diff --git a/frontend/src/lib/api/store-names/queries.ts b/frontend/src/lib/api/store-names/queries.ts new file mode 100644 index 00000000..2a259ea6 --- /dev/null +++ b/frontend/src/lib/api/store-names/queries.ts @@ -0,0 +1,10 @@ +import apiClient from "@/lib/api/api-base"; +import { StoreNameSuggestion } from "@/lib/api/types"; + +export async function getStoreNameSuggestions(): Promise< + StoreNameSuggestion[] +> { + const response = + await apiClient.get("/api/store-names"); + return response.data; +} diff --git a/frontend/src/lib/offline/cached-query-keys.ts b/frontend/src/lib/offline/cached-query-keys.ts index 6b24f7b7..5044941d 100644 --- a/frontend/src/lib/offline/cached-query-keys.ts +++ b/frontend/src/lib/offline/cached-query-keys.ts @@ -9,6 +9,7 @@ const PERSISTED_QUERY_KEY_PREFIXES = [ "shoppingListItems", "watchlist", "digitalCards", + "storeNames", // community store-name suggestions, so the card form works offline "pinnedStores", "pinnedPlaces", "users", // current user profile (["users", "me"]) diff --git a/frontend/src/lib/offline/offline-mutation-keys.ts b/frontend/src/lib/offline/offline-mutation-keys.ts index d1d8d75e..ad4671ef 100644 --- a/frontend/src/lib/offline/offline-mutation-keys.ts +++ b/frontend/src/lib/offline/offline-mutation-keys.ts @@ -11,6 +11,10 @@ export const OFFLINE_MUTATION_KEYS = { shoppingListItemDelete: ["shoppingLists", "items", "delete"], watchlistAdd: ["watchlist", "add"], watchlistRemove: ["watchlist", "remove"], + digitalCardCreate: ["digitalCards", "create"], + digitalCardUpdate: ["digitalCards", "update"], + digitalCardDelete: ["digitalCards", "delete"], + digitalCardSetPinned: ["digitalCards", "setPinned"], } as const satisfies Record; const OFFLINE_MUTATION_KEY_HASHES = new Set( diff --git a/frontend/src/lib/offline/offline-mutations.ts b/frontend/src/lib/offline/offline-mutations.ts index 0f079875..79ec7af2 100644 --- a/frontend/src/lib/offline/offline-mutations.ts +++ b/frontend/src/lib/offline/offline-mutations.ts @@ -9,14 +9,22 @@ import { deleteShoppingListItem, } from "@/lib/api/shopping-lists"; import { addToWatchlist, removeFromWatchlist } from "@/lib/api/watchlist"; +import { + createDigitalCard, + updateDigitalCard, + deleteDigitalCard, + setDigitalCardPinned, +} from "@/lib/api/digital-cards"; import type { ShoppingListRequest, ShoppingListItemRequest, WatchlistItemRequest, + DigitalCardRequest, } from "@/lib/api/types"; import { OFFLINE_MUTATION_KEYS } from "@/lib/offline/offline-mutation-keys"; const SHOPPING_LISTS_ME: QueryKey = ["shoppingLists", "me"]; +const DIGITAL_CARDS_ME: QueryKey = ["digitalCards", "me"]; function listAndItemsKeys(listId: string): QueryKey[] { return [ @@ -101,4 +109,30 @@ export function registerOfflineMutationDefaults(queryClient: QueryClient) { (id: string) => removeFromWatchlist(id), () => [["watchlist"]], ); + + defineOfflineMutation( + OFFLINE_MUTATION_KEYS.digitalCardCreate, + (data: DigitalCardRequest) => createDigitalCard(data), + () => [DIGITAL_CARDS_ME], + ); + + defineOfflineMutation( + OFFLINE_MUTATION_KEYS.digitalCardUpdate, + ({ id, data }: { id: string; data: DigitalCardRequest }) => + updateDigitalCard(id, data), + () => [DIGITAL_CARDS_ME], + ); + + defineOfflineMutation( + OFFLINE_MUTATION_KEYS.digitalCardDelete, + (id: string) => deleteDigitalCard(id), + () => [DIGITAL_CARDS_ME], + ); + + defineOfflineMutation( + OFFLINE_MUTATION_KEYS.digitalCardSetPinned, + ({ id, pinned }: { id: string; pinned: boolean }) => + setDigitalCardPinned(id, pinned), + () => [DIGITAL_CARDS_ME], + ); } From 0a0c992c62f8692ed1210b8cd9089170a8bbcf2d Mon Sep 17 00:00:00 2001 From: CrazyFreak <44674613+OffCrazyFreak@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:53:48 +0200 Subject: [PATCH 05/14] feat(digital-cards): Add code, colour and icon rendering primitives Changes: - Map stored code types onto bwip-js encoders and Croatian labels - Add generateCodeSvg, returning a result object rather than throwing - Add the card colour system: swatches, a locked hue scale, chain brand colours, hue sort - Add the six sort comparators with a pinned split and a Croatian tie break - Add dominant colour extraction from an uploaded image - Add CardCode and CardIcon, the two shared render pieces Colour is locked to one saturation and lightness pair, so every hue a user can reach stays legible under white text and the wallet reads as one designed set. Notes: - bwip-js does NOT tree shake. BWIPP is one generated blob, so importing thirteen encoders costs the same as importing all hundred: 844 KB raw, 210 KB gzipped, verified by finding unimported symbologies in the output. Webpack scopes it to the digital-cards route chunk, so no other page pays for it, and generateCodeSvg stays synchronous because a lazy import would only move the same download behind a loading state on a page where every tile draws a code. - Codabar's export is rationalizedCodabar, and bcid must be the bwip name rather than the scanner's format string. Both were wrong first time round. - includetext is off and the value line is our own HTML, which keeps user input out of the SVG string the panel injects. - CardIcon resolves the chain logo at render time instead of copying it into the card, so it never goes stale and costs nothing in the payload or the offline cache. - Greys sort last: hueFromHex reports them as neutral rather than as red. --- .../digital-cards/components/card-code.tsx | 54 ++++++++ .../digital-cards/components/card-icon.tsx | 62 ++++++++++ .../(user)/digital-cards/utils/card-colors.ts | 115 ++++++++++++++++++ .../(user)/digital-cards/utils/card-labels.ts | 30 +++++ .../digital-cards/utils/card-sorting.ts | 96 +++++++++++++++ .../digital-cards/utils/code-symbologies.ts | 91 ++++++++++++++ .../digital-cards/utils/generate-code-svg.ts | 93 ++++++++++++++ .../utils/browser/extract-dominant-color.ts | 75 ++++++++++++ 8 files changed, 616 insertions(+) create mode 100644 frontend/src/app/(user)/digital-cards/components/card-code.tsx create mode 100644 frontend/src/app/(user)/digital-cards/components/card-icon.tsx create mode 100644 frontend/src/app/(user)/digital-cards/utils/card-colors.ts create mode 100644 frontend/src/app/(user)/digital-cards/utils/card-labels.ts create mode 100644 frontend/src/app/(user)/digital-cards/utils/card-sorting.ts create mode 100644 frontend/src/app/(user)/digital-cards/utils/code-symbologies.ts create mode 100644 frontend/src/app/(user)/digital-cards/utils/generate-code-svg.ts create mode 100644 frontend/src/utils/browser/extract-dominant-color.ts 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-icon.tsx b/frontend/src/app/(user)/digital-cards/components/card-icon.tsx new file mode 100644 index 00000000..e0c3bbac --- /dev/null +++ b/frontend/src/app/(user)/digital-cards/components/card-icon.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { CreditCard } from "lucide-react"; + +import StoreChainLogo from "@/components/custom/store-chain/store-chain-logo"; +import { cn } from "@/lib/utils"; +import { getStoreInitials } from "@/app/(user)/digital-cards/utils/card-labels"; + +interface ICardIconProps { + iconImage: string | null; + chainCode: string | null; + storeName: string; + className?: string; +} + +/** + * The icon is resolved at render time rather than copied into the card on save: an official + * chain's logo stays current when the asset changes, and costs nothing in the payload. + */ +export default function CardIcon({ + iconImage, + chainCode, + storeName, + className, +}: ICardIconProps) { + const shell = cn( + "relative grid size-full place-items-center overflow-hidden rounded-full bg-white", + className, + ); + + if (iconImage) { + return ( +
+ {/* A data URI, so next/image would only add a proxy hop. */} + +
+ ); + } + + if (chainCode) { + return ( +
+ +
+ ); + } + + return ( +
+ {storeName.trim() ? ( + + ) : ( +
+ ); +} diff --git a/frontend/src/app/(user)/digital-cards/utils/card-colors.ts b/frontend/src/app/(user)/digital-cards/utils/card-colors.ts new file mode 100644 index 00000000..8e3277ca --- /dev/null +++ b/frontend/src/app/(user)/digital-cards/utils/card-colors.ts @@ -0,0 +1,115 @@ +/** + * Card colours are locked to one saturation and lightness pair, so every hue the user can + * reach stays legible under white text and the wallet reads as one designed set. The hue + * slider and the swatches are therefore the same system: a swatch is just a hue stop. + */ +const CARD_SATURATION = 62; +const CARD_LIGHTNESS = 42; + +export const DEFAULT_CARD_COLOR = "#64748b"; + +// Neutrals cannot come from the hue formula, so they are listed as literals and sort last. +export const NEUTRAL_CARD_COLORS = [ + DEFAULT_CARD_COLOR, + "#334155", + "#1f2937", +] as const; + +const SWATCH_HUES = [0, 20, 40, 55, 90, 150, 175, 200, 225, 265, 300, 330]; + +export function hslToHex(hue: number, saturation: number, lightness: number) { + const l = lightness / 100; + const chroma = (1 - Math.abs(2 * l - 1)) * (saturation / 100); + + const channel = (offset: number) => { + const k = (offset + hue / 30) % 12; + const value = l - (chroma / 2) * Math.max(-1, Math.min(k - 3, 9 - k, 1)); + + return Math.round(255 * value) + .toString(16) + .padStart(2, "0"); + }; + + return `#${channel(0)}${channel(8)}${channel(4)}`; +} + +/** The hex a given hue produces, which is what both the slider and the swatches emit. */ +export function hexForHue(hue: number): string { + return hslToHex(hue, CARD_SATURATION, CARD_LIGHTNESS); +} + +export const CARD_SWATCHES: string[] = [ + ...SWATCH_HUES.map(hexForHue), + ...NEUTRAL_CARD_COLORS, +]; + +export function hexToRgb(hex: string): [number, number, number] { + const normalized = hex.replace("#", ""); + + return [ + parseInt(normalized.slice(0, 2), 16), + parseInt(normalized.slice(2, 4), 16), + parseInt(normalized.slice(4, 6), 16), + ]; +} + +export interface IHueInfo { + hue: number; + /** Below this, a colour is a grey and has no meaningful hue to sort by. */ + isNeutral: boolean; +} + +export function hueFromHex(hex: string): IHueInfo { + const [r, g, b] = hexToRgb(hex).map((channel) => channel / 255); + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const delta = max - min; + + if (delta < 0.08) return { hue: 0, isNeutral: true }; + + let hue: number; + if (max === r) hue = ((g - b) / delta) % 6; + else if (max === g) hue = (b - r) / delta + 2; + else hue = (r - g) / delta + 4; + + hue = Math.round(hue * 60); + + return { hue: hue < 0 ? hue + 360 : hue, isNeutral: false }; +} + +/** + * Curated brand colours for the official chains, used to prefill a new card's colour. + * Deliberately hand-picked rather than sampled from the logo PNGs: several logos are + * mostly white, and a few brand reds are too light to carry white text. + */ +export const CHAIN_BRAND_COLORS: Record = { + konzum: "#c8102e", + lidl: "#0050aa", + plodine: "#e2001a", + tommy: "#d81f26", + spar: "#007a33", + kaufland: "#e10915", + studenac: "#0a5c36", + eurospin: "#f47b20", + dm: "#00589c", + metro: "#003d7c", + ktc: "#0a6ebd", + ntl: "#1b5e20", + ribola: "#0072bc", + boso: "#c62828", + bure: "#8d6e63", + vrutak: "#2e7d32", + zabac: "#43a047", + roto: "#1565c0", + branka: "#ad1457", + brodokomerc: "#00695c", + gavranovic: "#b71c1c", + jadranka_trgovina: "#0277bd", + lorenco: "#6a1b9a", + stanic: "#37474f", + stridon: "#455a64", + trgocentar: "#ef6c00", + "trgovina-krk": "#0288d1", + djelo_vodice: "#00838f", + dukat: "#1976d2", +}; diff --git a/frontend/src/app/(user)/digital-cards/utils/card-labels.ts b/frontend/src/app/(user)/digital-cards/utils/card-labels.ts new file mode 100644 index 00000000..a62e239a --- /dev/null +++ b/frontend/src/app/(user)/digital-cards/utils/card-labels.ts @@ -0,0 +1,30 @@ +import type { CardType } from "@/constants/card-codes"; + +const CARD_TYPE_LABELS: Record = { + loyalty: "Kartica vjernosti", + gift: "Poklon kartica", + membership: "Članska kartica", + other: "Ostalo", +}; + +export function getCardTypeLabel(cardType: CardType): string { + return CARD_TYPE_LABELS[cardType]; +} + +export function getCardTypeOptions(): { value: CardType; label: string }[] { + return (Object.keys(CARD_TYPE_LABELS) as CardType[]).map((value) => ({ + value, + label: CARD_TYPE_LABELS[value], + })); +} + +/** Up to two letters, so a card with no icon and no chain logo still reads as itself. */ +export function getStoreInitials(storeName: string): string { + const words = storeName.trim().split(/\s+/).filter(Boolean); + if (words.length === 0) return "?"; + + const initials = + words.length === 1 ? words[0].slice(0, 2) : `${words[0][0]}${words[1][0]}`; + + return initials.toUpperCase(); +} diff --git a/frontend/src/app/(user)/digital-cards/utils/card-sorting.ts b/frontend/src/app/(user)/digital-cards/utils/card-sorting.ts new file mode 100644 index 00000000..47536f06 --- /dev/null +++ b/frontend/src/app/(user)/digital-cards/utils/card-sorting.ts @@ -0,0 +1,96 @@ +import type { DigitalCardDto } from "@/lib/api/types"; +import { compareHr } from "@/utils/strings"; +import type { CardType } from "@/constants/card-codes"; +import { hueFromHex } from "@/app/(user)/digital-cards/utils/card-colors"; +import { getCardTypeLabel } from "@/app/(user)/digital-cards/utils/card-labels"; + +export const CARD_SORT_MODES = [ + "cardName", + "storeName", + "cardType", + "updatedAt", + "createdAt", + "cardColor", +] as const; + +export type CardSortMode = (typeof CARD_SORT_MODES)[number]; + +export const DEFAULT_CARD_SORT: CardSortMode = "updatedAt"; + +export function isCardSortMode(value: unknown): value is CardSortMode { + return ( + typeof value === "string" && + (CARD_SORT_MODES as readonly string[]).includes(value) + ); +} + +export const CARD_SORT_LABELS: Record = { + cardName: "Naziv kartice", + storeName: "Trgovina", + cardType: "Tip kartice", + updatedAt: "Nedavno uređeno", + createdAt: "Nedavno dodano", + cardColor: "Boja", +}; + +function byNewestFirst(a: string, b: string): number { + return b.localeCompare(a); +} + +/** Greys have no hue, so they collect at the end instead of landing on red. */ +function compareColor(a: string, b: string): number { + const left = hueFromHex(a); + const right = hueFromHex(b); + + if (left.isNeutral !== right.isNeutral) return left.isNeutral ? 1 : -1; + if (left.isNeutral && right.isNeutral) return a.localeCompare(b); + if (left.hue !== right.hue) return left.hue - right.hue; + + // Same hue, different shade: keep identical colours adjacent. + return a.localeCompare(b); +} + +function compareBy(mode: CardSortMode) { + return (a: DigitalCardDto, b: DigitalCardDto): number => { + switch (mode) { + case "cardName": + return compareHr(a.cardName, b.cardName); + case "storeName": + return compareHr(a.storeName, b.storeName); + case "cardType": + return compareHr( + getCardTypeLabel(a.cardType as CardType), + getCardTypeLabel(b.cardType as CardType), + ); + case "updatedAt": + return byNewestFirst(a.updatedAt, b.updatedAt); + case "createdAt": + return byNewestFirst(a.createdAt, b.createdAt); + case "cardColor": + return compareColor(a.cardColor, b.cardColor); + } + }; +} + +export interface ISortedCardGroups { + pinned: DigitalCardDto[]; + rest: DigitalCardDto[]; +} + +/** + * Pinned cards keep their own group at the top, but they are sorted by the same mode as + * the rest, so the chosen order still means something inside it. + */ +export function groupAndSortCards( + cards: DigitalCardDto[], + mode: CardSortMode, +): ISortedCardGroups { + const compare = compareBy(mode); + const withTieBreak = (a: DigitalCardDto, b: DigitalCardDto) => + compare(a, b) || compareHr(a.cardName, b.cardName); + + return { + pinned: cards.filter((card) => card.pinnedAt).sort(withTieBreak), + rest: cards.filter((card) => !card.pinnedAt).sort(withTieBreak), + }; +} diff --git a/frontend/src/app/(user)/digital-cards/utils/code-symbologies.ts b/frontend/src/app/(user)/digital-cards/utils/code-symbologies.ts new file mode 100644 index 00000000..fe27ac33 --- /dev/null +++ b/frontend/src/app/(user)/digital-cards/utils/code-symbologies.ts @@ -0,0 +1,91 @@ +import { CODE_TYPES, type CodeType } from "@/constants/card-codes"; + +/** + * Barcode Detection API format name to the bwip-js encoder name. The scanner speaks the + * former and bwip-js the latter, so this map is the only place the two vocabularies meet. + */ +export const CODE_TYPE_TO_BCID: Record, string> = { + ean_13: "ean13", + ean_8: "ean8", + upc_a: "upca", + upc_e: "upce", + code_128: "code128", + code_39: "code39", + code_93: "code93", + itf: "interleaved2of5", + codabar: "codabar", + qr_code: "qrcode", + data_matrix: "datamatrix", + pdf417: "pdf417", + aztec: "azteccode", +}; + +const CODE_TYPE_LABELS: Record = { + ean_13: "EAN-13", + ean_8: "EAN-8", + upc_a: "UPC-A", + upc_e: "UPC-E", + code_128: "Code 128", + code_39: "Code 39", + code_93: "Code 93", + itf: "ITF (Interleaved 2 of 5)", + codabar: "Codabar", + qr_code: "QR kod", + data_matrix: "Data Matrix", + pdf417: "PDF417", + aztec: "Aztec", + unknown: "Samo broj/tekst", +}; + +// The handful worth surfacing first: what Croatian loyalty cards actually carry. +const COMMON_CODE_TYPES: CodeType[] = [ + "ean_13", + "ean_8", + "code_128", + "code_39", + "qr_code", + "upc_a", +]; + +export function getCodeTypeLabel(codeType: CodeType): string { + return CODE_TYPE_LABELS[codeType]; +} + +export interface ICodeTypeGroup { + label: string; + options: { value: CodeType; label: string }[]; +} + +/** + * One grouped list rather than a curated subset, so a scan that returns an unusual + * format still has a matching option instead of being silently dropped. + */ +export function getCodeTypeGroups(): ICodeTypeGroup[] { + const rest = CODE_TYPES.filter((type) => !COMMON_CODE_TYPES.includes(type)); + + return [ + { + label: "Uobičajeni", + options: COMMON_CODE_TYPES.map((value) => ({ + value, + label: CODE_TYPE_LABELS[value], + })), + }, + { + label: "Ostali", + options: rest.map((value) => ({ + value, + label: CODE_TYPE_LABELS[value], + })), + }, + ]; +} + +/** Narrows an arbitrary scanner format string onto our stored vocabulary. */ +export function toCodeType(format: string | undefined): CodeType { + if (!format) return "unknown"; + + return (CODE_TYPES as readonly string[]).includes(format) + ? (format as CodeType) + : "unknown"; +} diff --git a/frontend/src/app/(user)/digital-cards/utils/generate-code-svg.ts b/frontend/src/app/(user)/digital-cards/utils/generate-code-svg.ts new file mode 100644 index 00000000..7313794e --- /dev/null +++ b/frontend/src/app/(user)/digital-cards/utils/generate-code-svg.ts @@ -0,0 +1,93 @@ +import { + drawingSVG, + ean13, + ean8, + upca, + upce, + code128, + code39, + code93, + interleaved2of5, + rationalizedCodabar, + qrcode, + datamatrix, + pdf417, + azteccode, + type RenderOptions, +} from "@bwip-js/browser"; + +import type { CodeType } from "@/constants/card-codes"; +import { CODE_TYPE_TO_BCID } from "@/app/(user)/digital-cards/utils/code-symbologies"; + +type Encoder = (opts: RenderOptions, drawing: unknown) => string; + +// Named imports do NOT tree-shake: BWIPP is one generated blob, so any single encoder pulls +// the whole table (measured at 844 KB raw, 210 KB gzipped). Webpack keeps that in the +// digital-cards route chunk rather than a shared one, so no other page pays for it. +// Kept synchronous on purpose: every card tile renders a code, so a lazy import would only +// move the same download behind a loading state. +const ENCODERS: Record, Encoder> = { + ean_13: ean13 as Encoder, + ean_8: ean8 as Encoder, + upc_a: upca as Encoder, + upc_e: upce as Encoder, + code_128: code128 as Encoder, + code_39: code39 as Encoder, + code_93: code93 as Encoder, + itf: interleaved2of5 as Encoder, + codabar: rationalizedCodabar as Encoder, + qr_code: qrcode as Encoder, + data_matrix: datamatrix as Encoder, + pdf417: pdf417 as Encoder, + aztec: azteccode as Encoder, +}; + +export type CodeSvgResult = + { ok: true; svg: string } | { ok: false; reason: "unsupported" | "invalid" }; + +const TWO_DIMENSIONAL: CodeType[] = [ + "qr_code", + "data_matrix", + "pdf417", + "aztec", +]; + +export function isTwoDimensional(codeType: CodeType): boolean { + return TWO_DIMENSIONAL.includes(codeType); +} + +/** + * Never throws: bwip-js rejects a value that does not fit its symbology (an EAN-13 needs + * twelve digits), and the caller renders the plain value instead of a broken code. + */ +export function generateCodeSvg( + codeValue: string, + codeType: CodeType, +): CodeSvgResult { + if (codeType === "unknown") return { ok: false, reason: "unsupported" }; + + const value = codeValue.trim(); + if (!value) return { ok: false, reason: "invalid" }; + + try { + // includetext stays off so the human-readable line is our own HTML: it keeps user + // input out of the SVG string that the code panel injects. + const options: RenderOptions = { + bcid: CODE_TYPE_TO_BCID[codeType], + text: value, + includetext: false, + scale: 3, + paddingwidth: 0, + paddingheight: 0, + }; + + // A 2D symbology sizes itself; height on a 1D one is the bar height in millimetres. + if (!isTwoDimensional(codeType)) options.height = 14; + + const svg = ENCODERS[codeType](options, drawingSVG()); + + return { ok: true, svg }; + } catch { + return { ok: false, reason: "invalid" }; + } +} diff --git a/frontend/src/utils/browser/extract-dominant-color.ts b/frontend/src/utils/browser/extract-dominant-color.ts new file mode 100644 index 00000000..9d622acd --- /dev/null +++ b/frontend/src/utils/browser/extract-dominant-color.ts @@ -0,0 +1,75 @@ +import { + hexForHue, + hueFromHex, +} from "@/app/(user)/digital-cards/utils/card-colors"; + +const SAMPLE_SIZE = 48; + +/** + * Best-effort dominant hue of an image, used to prefill a card's colour from its logo or + * photo. Returns null rather than guessing badly, and the caller keeps the current colour. + * + * Near-white, near-black and washed-out pixels are skipped: a card photo is mostly plastic + * whitespace, and letting that win would make every suggestion grey. + */ +export default async function extractDominantColor( + source: Blob, +): Promise { + try { + const bitmap = await createImageBitmap(source); + const canvas = document.createElement("canvas"); + canvas.width = SAMPLE_SIZE; + canvas.height = SAMPLE_SIZE; + + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (!context) return null; + + context.drawImage(bitmap, 0, 0, SAMPLE_SIZE, SAMPLE_SIZE); + bitmap.close(); + + const { data } = context.getImageData(0, 0, SAMPLE_SIZE, SAMPLE_SIZE); + const buckets = new Map(); + const centre = SAMPLE_SIZE / 2; + + for (let index = 0; index < data.length; index += 4) { + const [r, g, b, alpha] = [ + data[index], + data[index + 1], + data[index + 2], + data[index + 3], + ]; + if (alpha < 200) continue; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + if (max > 240 || max < 24 || max - min < 28) continue; + + const hex = `#${[r, g, b] + .map((channel) => channel.toString(16).padStart(2, "0")) + .join("")}`; + const { hue, isNeutral } = hueFromHex(hex); + if (isNeutral) continue; + + // Weight the middle of the frame, where a logo usually sits. + const pixel = index / 4; + const x = pixel % SAMPLE_SIZE; + const y = Math.floor(pixel / SAMPLE_SIZE); + const distance = Math.hypot(x - centre, y - centre) / centre; + const weight = 1 + (1 - Math.min(distance, 1)); + + // 15 degree buckets, so one gradient does not split across many hues. + const bucket = Math.round(hue / 15) % 24; + buckets.set(bucket, (buckets.get(bucket) ?? 0) + weight); + } + + if (buckets.size === 0) return null; + + const [dominant] = [...buckets.entries()].sort((a, b) => b[1] - a[1])[0]; + + // Snap onto the card palette's fixed saturation and lightness, so an extracted colour + // is indistinguishable from a picked one and still passes contrast. + return hexForHue(dominant * 15); + } catch { + return null; + } +} From aa290408f9bb2b602c391b9748f3cdd8500155c3 Mon Sep 17 00:00:00 2001 From: CrazyFreak <44674613+OffCrazyFreak@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:54:15 +0200 Subject: [PATCH 06/14] feat(digital-cards): Add the card form and checkout modals Changes: - Register digital-card as a URL driven modal with new, edit and view actions - Add the create and edit form: name, store autocomplete, type, code, colour, images, note - Add scan to fill, which sets both the code and its detected type - Add a live code preview that warns when the chosen symbology cannot encode the value - Add three colour picker variants behind a temporary switcher - Add the checkout modal: large high contrast code, card faces, pin, edit, delete - Add useWakeLock, holding the screen on while a code is displayed The two modals ship together because the entity outlet renders both, so splitting them would leave it importing a module that does not exist yet. Notes: - View pushes to edit rather than swapping, so cancelling lands back on the card instead of the page, which is what closeModalUrl's history.back gives for free. - The store field sets chainCode alongside storeName for an official chain and nulls it for free text, which is what gates the logo and the brand colour. - Colour is suggested from the chain, then from an uploaded image, but only while the user has not touched the picker. A manual pick wins permanently. - Images sit outside react-hook-form with their own dirty flag, folded into the shell's indicator. Drafts exclude the card code, so a number never reaches disk. - Cards are read from the /me list rather than a by id endpoint, which is what makes a deep link work offline, and useCardImages adjusts state during render like use-lingering-target does, so an arriving card cannot flash the previous one's images. - The picker switcher is deliberately temporary: two variants and the switcher get deleted once one is chosen. --- .../components/forms/card-image-slot.tsx | 101 ++++++++++++ .../components/forms/card-images-field.tsx | 64 +++++++ .../components/forms/card-name-field.tsx | 33 ++++ ...d-select-field.tsx => card-type-field.tsx} | 26 +-- .../components/forms/code-field.tsx | 82 +++++++++ .../components/forms/code-preview.tsx | 56 +++++++ .../components/forms/code-type-field.tsx | 63 +++++++ .../forms/color-picker/card-color-field.tsx | 64 +++++++ .../color-picker/color-picker-honeycomb.tsx | 97 +++++++++++ .../color-picker-swatch-slider.tsx | 25 +++ .../color-picker/color-picker-swatches.tsx | 50 ++++++ .../color-picker/color-picker-switcher.tsx | 46 ++++++ .../forms/color-picker/hue-slider.tsx | 40 +++++ .../components/forms/digital-card-fields.tsx | 123 -------------- .../components/forms/digital-card-modal.tsx | 156 ++++++++++++------ .../components/forms/note-field.tsx | 39 +++++ .../components/forms/store-name-field.tsx | 141 ++++++++++++++++ .../components/forms/store-name-option.tsx | 37 +++++ .../components/forms/store-name-trigger.tsx | 68 ++++++++ .../components/pin-toggle-button.tsx | 58 +++++++ .../components/view/card-code-panel.tsx | 50 ++++++ .../components/view/card-detail-rows.tsx | 35 ++++ .../components/view/card-face-images.tsx | 67 ++++++++ .../components/view/card-view-actions.tsx | 93 +++++++++++ .../view/digital-card-view-modal.tsx | 82 +++++++++ .../digital-cards/hooks/use-card-images.ts | 60 +++++++ .../hooks/use-digital-card-modal.ts | 8 +- .../digital-cards/hooks/use-digital-card.ts | 33 ++++ .../hooks/use-store-name-options.ts | 58 +++++++ .../modal-router/entity-modal-outlet.tsx | 25 ++- frontend/src/hooks/use-wake-lock.ts | 53 ++++++ frontend/src/lib/modal/modal-registry.ts | 5 + 32 files changed, 1740 insertions(+), 198 deletions(-) create mode 100644 frontend/src/app/(user)/digital-cards/components/forms/card-image-slot.tsx create mode 100644 frontend/src/app/(user)/digital-cards/components/forms/card-images-field.tsx create mode 100644 frontend/src/app/(user)/digital-cards/components/forms/card-name-field.tsx rename frontend/src/app/(user)/digital-cards/components/forms/{digital-card-select-field.tsx => card-type-field.tsx} (64%) create mode 100644 frontend/src/app/(user)/digital-cards/components/forms/code-field.tsx create mode 100644 frontend/src/app/(user)/digital-cards/components/forms/code-preview.tsx create mode 100644 frontend/src/app/(user)/digital-cards/components/forms/code-type-field.tsx create mode 100644 frontend/src/app/(user)/digital-cards/components/forms/color-picker/card-color-field.tsx create mode 100644 frontend/src/app/(user)/digital-cards/components/forms/color-picker/color-picker-honeycomb.tsx create mode 100644 frontend/src/app/(user)/digital-cards/components/forms/color-picker/color-picker-swatch-slider.tsx create mode 100644 frontend/src/app/(user)/digital-cards/components/forms/color-picker/color-picker-swatches.tsx create mode 100644 frontend/src/app/(user)/digital-cards/components/forms/color-picker/color-picker-switcher.tsx create mode 100644 frontend/src/app/(user)/digital-cards/components/forms/color-picker/hue-slider.tsx delete mode 100644 frontend/src/app/(user)/digital-cards/components/forms/digital-card-fields.tsx create mode 100644 frontend/src/app/(user)/digital-cards/components/forms/note-field.tsx create mode 100644 frontend/src/app/(user)/digital-cards/components/forms/store-name-field.tsx create mode 100644 frontend/src/app/(user)/digital-cards/components/forms/store-name-option.tsx create mode 100644 frontend/src/app/(user)/digital-cards/components/forms/store-name-trigger.tsx create mode 100644 frontend/src/app/(user)/digital-cards/components/pin-toggle-button.tsx create mode 100644 frontend/src/app/(user)/digital-cards/components/view/card-code-panel.tsx create mode 100644 frontend/src/app/(user)/digital-cards/components/view/card-detail-rows.tsx create mode 100644 frontend/src/app/(user)/digital-cards/components/view/card-face-images.tsx create mode 100644 frontend/src/app/(user)/digital-cards/components/view/card-view-actions.tsx create mode 100644 frontend/src/app/(user)/digital-cards/components/view/digital-card-view-modal.tsx create mode 100644 frontend/src/app/(user)/digital-cards/hooks/use-card-images.ts create mode 100644 frontend/src/app/(user)/digital-cards/hooks/use-digital-card.ts create mode 100644 frontend/src/app/(user)/digital-cards/hooks/use-store-name-options.ts create mode 100644 frontend/src/hooks/use-wake-lock.ts diff --git a/frontend/src/app/(user)/digital-cards/components/forms/card-image-slot.tsx b/frontend/src/app/(user)/digital-cards/components/forms/card-image-slot.tsx new file mode 100644 index 00000000..ef446ccd --- /dev/null +++ b/frontend/src/app/(user)/digital-cards/components/forms/card-image-slot.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { ChangeEvent, useId } from "react"; +import { Camera } from "lucide-react"; +import { toast } from "sonner"; + +import { cn } from "@/lib/utils"; +import resizeImageToWebp from "@/utils/browser/image"; + +// Only guards decoding: the WebP re-encode below sets the stored size. +const MAX_SOURCE_BYTES = 15 * 1024 * 1024; + +interface ICardImageSlotProps { + label: string; + value: string | null; + /** Long edge in pixels after the WebP re-encode. */ + maxSize: number; + /** Character cap the backend enforces on the data URI. */ + maxLength: number; + onChange: (value: string | null) => void; + onPicked?: (file: File) => void; + className?: string; +} + +export default function CardImageSlot({ + label, + value, + maxSize, + maxLength, + onChange, + onPicked, + className, +}: ICardImageSlotProps) { + const inputId = useId(); + + async function handleChange(event: ChangeEvent) { + const file = event.target.files?.[0]; + if (!file) return; + + if (file.size > MAX_SOURCE_BYTES) { + toast.error("Slika je prevelika. Maksimalna veličina je 15 MB."); + return; + } + + try { + const encoded = await resizeImageToWebp(file, maxSize); + + // Catches the doomed request before it is sent: @Size counts characters and a data + // URI is ASCII, so the cap the backend applies is the same number. + if (encoded.length > maxLength) { + toast.error("Slika je prevelika, pokušaj s manjom fotografijom."); + return; + } + + onChange(encoded); + onPicked?.(file); + } catch { + toast.error("Sliku nije moguće učitati, probaj JPG ili PNG."); + } + } + + return ( +
+ + + + + {label} + + {value && ( + + )} +
+ ); +} diff --git a/frontend/src/app/(user)/digital-cards/components/forms/card-images-field.tsx b/frontend/src/app/(user)/digital-cards/components/forms/card-images-field.tsx new file mode 100644 index 00000000..3a141de6 --- /dev/null +++ b/frontend/src/app/(user)/digital-cards/components/forms/card-images-field.tsx @@ -0,0 +1,64 @@ +"use client"; + +import CardImageSlot from "@/app/(user)/digital-cards/components/forms/card-image-slot"; +import type { + CardImageSlot as SlotName, + ICardImages, +} from "@/app/(user)/digital-cards/hooks/use-card-images"; + +interface ICardImagesFieldProps { + images: ICardImages; + onImageChange: (slot: SlotName, value: string | null) => void; + /** Fires for the icon and front slots, which can suggest the card's colour. */ + onColorSource: (file: File) => void; +} + +// The icon is a small medallion, so 256px is plenty. The card faces are the backup a +// cashier reads off the screen, so they get four times the detail. +const ICON_MAX_SIZE = 256; +const FACE_MAX_SIZE = 1024; +const ICON_MAX_LENGTH = 400_000; +const FACE_MAX_LENGTH = 1_200_000; + +export default function CardImagesField({ + images, + onImageChange, + onColorSource, +}: ICardImagesFieldProps) { + return ( +
+

Slike kartice

+ +
+ onImageChange("iconImage", value)} + onPicked={onColorSource} + /> + onImageChange("frontImage", value)} + onPicked={onColorSource} + /> + onImageChange("backImage", value)} + /> +
+ +

+ Slike prednje i stražnje strane su rezerva ako kod ne proradi na + blagajni. +

+
+ ); +} diff --git a/frontend/src/app/(user)/digital-cards/components/forms/card-name-field.tsx b/frontend/src/app/(user)/digital-cards/components/forms/card-name-field.tsx new file mode 100644 index 00000000..edef4737 --- /dev/null +++ b/frontend/src/app/(user)/digital-cards/components/forms/card-name-field.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { useFormContext } from "react-hook-form"; + +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import type { DigitalCardFormData } from "@/lib/api/types"; + +export default function CardNameField() { + const form = useFormContext(); + + return ( + ( + + Naziv kartice + + + + + + )} + /> + ); +} diff --git a/frontend/src/app/(user)/digital-cards/components/forms/digital-card-select-field.tsx b/frontend/src/app/(user)/digital-cards/components/forms/card-type-field.tsx similarity index 64% rename from frontend/src/app/(user)/digital-cards/components/forms/digital-card-select-field.tsx rename to frontend/src/app/(user)/digital-cards/components/forms/card-type-field.tsx index db8ef910..8edec43e 100644 --- a/frontend/src/app/(user)/digital-cards/components/forms/digital-card-select-field.tsx +++ b/frontend/src/app/(user)/digital-cards/components/forms/card-type-field.tsx @@ -16,34 +16,24 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import type { DigitalCardRequest } from "@/lib/api/types"; +import type { DigitalCardFormData } from "@/lib/api/types"; +import { getCardTypeOptions } from "@/app/(user)/digital-cards/utils/card-labels"; -interface IDigitalCardSelectFieldProps { - name: "type" | "codeType"; - label: string; - placeholder: string; - options: { value: string; label: string }[]; -} - -export default function DigitalCardSelectField({ - name, - label, - placeholder, - options, -}: IDigitalCardSelectFieldProps) { - const form = useFormContext(); +export default function CardTypeField() { + const form = useFormContext(); + const options = getCardTypeOptions(); return ( ( - {label} + Tip kartice + + + + + + + + Skeniraj kod kartice + + +
+ + Skeniraj kod s kartice ili ga upiši ručno. + + + + )} + /> + ); +} diff --git a/frontend/src/app/(user)/digital-cards/components/forms/code-preview.tsx b/frontend/src/app/(user)/digital-cards/components/forms/code-preview.tsx new file mode 100644 index 00000000..210f0a8c --- /dev/null +++ b/frontend/src/app/(user)/digital-cards/components/forms/code-preview.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useFormContext } from "react-hook-form"; +import { TriangleAlert } from "lucide-react"; + +import type { DigitalCardFormData } from "@/lib/api/types"; +import { generateCodeSvg } from "@/app/(user)/digital-cards/utils/generate-code-svg"; + +/** + * Confirms the chosen symbology actually encodes the value before the card is saved, which + * is the whole reason codeType is user-editable: a wrong guess is invisible until a cashier + * cannot scan it. + */ +export default function CodePreview() { + const form = useFormContext(); + const codeValue = form.watch("codeValue"); + const codeType = form.watch("codeType"); + + if (!codeValue?.trim()) return null; + + const result = generateCodeSvg(codeValue, codeType); + + return ( +
+

Prikaz koda

+ +
+ {result.ok ? ( +
+ ) : ( +

+ {codeValue} +

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

+

+ )} +
+ ); +} diff --git a/frontend/src/app/(user)/digital-cards/components/forms/code-type-field.tsx b/frontend/src/app/(user)/digital-cards/components/forms/code-type-field.tsx new file mode 100644 index 00000000..e48f2ba4 --- /dev/null +++ b/frontend/src/app/(user)/digital-cards/components/forms/code-type-field.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useFormContext } from "react-hook-form"; + +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { DigitalCardFormData } from "@/lib/api/types"; +import { getCodeTypeGroups } from "@/app/(user)/digital-cards/utils/code-symbologies"; + +export default function CodeTypeField() { + const form = useFormContext(); + const groups = getCodeTypeGroups(); + + return ( + ( + + Tip koda + + + Provjeri prikaz koda ispod. Ako se ne prikazuje, probaj drugi tip. + + + + )} + /> + ); +} diff --git a/frontend/src/app/(user)/digital-cards/components/forms/color-picker/card-color-field.tsx b/frontend/src/app/(user)/digital-cards/components/forms/color-picker/card-color-field.tsx new file mode 100644 index 00000000..4aa626b9 --- /dev/null +++ b/frontend/src/app/(user)/digital-cards/components/forms/color-picker/card-color-field.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { useState } from "react"; +import { useFormContext } from "react-hook-form"; + +import { FormDescription, FormLabel, FormMessage } from "@/components/ui/form"; +import type { DigitalCardFormData } from "@/lib/api/types"; +import ColorPickerHoneycomb from "@/app/(user)/digital-cards/components/forms/color-picker/color-picker-honeycomb"; +import ColorPickerSwatchSlider from "@/app/(user)/digital-cards/components/forms/color-picker/color-picker-swatch-slider"; +import ColorPickerSwatches from "@/app/(user)/digital-cards/components/forms/color-picker/color-picker-swatches"; +import ColorPickerSwitcher, { + type ColorPickerVariant, +} from "@/app/(user)/digital-cards/components/forms/color-picker/color-picker-switcher"; + +interface ICardColorFieldProps { + /** True once the user picks a colour, after which nothing auto-fills over them. */ + isCustomized: boolean; + onCustomize: () => void; +} + +export default function CardColorField({ + isCustomized, + onCustomize, +}: ICardColorFieldProps) { + const form = useFormContext(); + const [variant, setVariant] = useState("swatch-slider"); + + const value = form.watch("cardColor"); + + function handleChange(hex: string) { + form.setValue("cardColor", hex, { + shouldDirty: true, + shouldValidate: true, + }); + onCustomize(); + } + + return ( +
+
+ Boja kartice + +
+ + {variant === "swatch-slider" && ( + + )} + {variant === "honeycomb" && ( + + )} + {variant === "swatches" && ( + + )} + + + {isCustomized + ? "Boja pomaže da karticu prepoznaš na prvi pogled." + : "Boju predlažemo iz trgovine ili slike, a možeš je promijeniti."} + + + +
+ ); +} diff --git a/frontend/src/app/(user)/digital-cards/components/forms/color-picker/color-picker-honeycomb.tsx b/frontend/src/app/(user)/digital-cards/components/forms/color-picker/color-picker-honeycomb.tsx new file mode 100644 index 00000000..aafaaab4 --- /dev/null +++ b/frontend/src/app/(user)/digital-cards/components/forms/color-picker/color-picker-honeycomb.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { hslToHex } from "@/app/(user)/digital-cards/utils/card-colors"; +import { NEUTRAL_CARD_COLORS } from "@/app/(user)/digital-cards/utils/card-colors"; +import { cn } from "@/lib/utils"; + +interface IColorPickerHoneycombProps { + value: string; + onChange: (hex: string) => void; +} + +// Three rings of hues, saturation easing outward so the middle reads as the calm choice and +// the rim as the bold one. Row lengths are what give the comb its stagger. +const ROWS: { saturation: number; lightness: number; count: number }[] = [ + { saturation: 38, lightness: 50, count: 7 }, + { saturation: 62, lightness: 42, count: 8 }, + { saturation: 78, lightness: 34, count: 7 }, +]; + +const HEX_CLIP = + "polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)"; + +/** + * Variant B: a honeycomb. Hexagons tessellate without gaps, so the palette reads as one + * surface rather than a grid of dots, and the shape itself is the memorable part. + */ +export default function ColorPickerHoneycomb({ + value, + onChange, +}: IColorPickerHoneycombProps) { + const selected = value.toLowerCase(); + + return ( +
+ {ROWS.map((row, rowIndex) => ( +
+ {Array.from({ length: row.count }, (_, cellIndex) => { + const hue = Math.round((360 / row.count) * cellIndex); + const hex = hslToHex(hue, row.saturation, row.lightness); + const isSelected = hex.toLowerCase() === selected; + + return ( +
+ ))} + +
+ {NEUTRAL_CARD_COLORS.map((hex) => { + const isSelected = hex.toLowerCase() === selected; + + return ( +
+
+ ); +} diff --git a/frontend/src/app/(user)/digital-cards/components/forms/color-picker/color-picker-swatch-slider.tsx b/frontend/src/app/(user)/digital-cards/components/forms/color-picker/color-picker-swatch-slider.tsx new file mode 100644 index 00000000..9ad8d2d2 --- /dev/null +++ b/frontend/src/app/(user)/digital-cards/components/forms/color-picker/color-picker-swatch-slider.tsx @@ -0,0 +1,25 @@ +"use client"; + +import ColorPickerSwatches from "@/app/(user)/digital-cards/components/forms/color-picker/color-picker-swatches"; +import HueSlider from "@/app/(user)/digital-cards/components/forms/color-picker/hue-slider"; + +interface IColorPickerSwatchSliderProps { + value: string; + onChange: (hex: string) => void; +} + +/** + * Variant A: one tap for the common case, one drag for anything in between. The swatches + * and the slider are the same scale, so tapping a swatch simply moves the handle. + */ +export default function ColorPickerSwatchSlider({ + value, + onChange, +}: IColorPickerSwatchSliderProps) { + return ( +
+ + +
+ ); +} diff --git a/frontend/src/app/(user)/digital-cards/components/forms/color-picker/color-picker-swatches.tsx b/frontend/src/app/(user)/digital-cards/components/forms/color-picker/color-picker-swatches.tsx new file mode 100644 index 00000000..909b9ed0 --- /dev/null +++ b/frontend/src/app/(user)/digital-cards/components/forms/color-picker/color-picker-swatches.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { Check } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { CARD_SWATCHES } from "@/app/(user)/digital-cards/utils/card-colors"; + +interface IColorPickerSwatchesProps { + value: string; + onChange: (hex: string) => void; + className?: string; +} + +/** Variant C, the control condition: swatches only, nothing else to learn. */ +export default function ColorPickerSwatches({ + value, + onChange, + className, +}: IColorPickerSwatchesProps) { + return ( +
+ {CARD_SWATCHES.map((hex) => { + const isSelected = hex.toLowerCase() === value.toLowerCase(); + + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/app/(user)/digital-cards/components/forms/color-picker/color-picker-switcher.tsx b/frontend/src/app/(user)/digital-cards/components/forms/color-picker/color-picker-switcher.tsx new file mode 100644 index 00000000..f1c1d32c --- /dev/null +++ b/frontend/src/app/(user)/digital-cards/components/forms/color-picker/color-picker-switcher.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { cn } from "@/lib/utils"; + +export const COLOR_PICKER_VARIANTS = [ + { id: "swatch-slider", label: "Paleta i nijansa" }, + { id: "honeycomb", label: "Šesterokuti" }, + { id: "swatches", label: "Samo paleta" }, +] as const; + +export type ColorPickerVariant = (typeof COLOR_PICKER_VARIANTS)[number]["id"]; + +interface IColorPickerSwitcherProps { + value: ColorPickerVariant; + onChange: (variant: ColorPickerVariant) => void; +} + +/** + * TODO(digital-cards): temporary. Three pickers ship side by side so the final one can be + * chosen from real use; delete this switcher and the two losing variants once it is picked. + */ +export default function ColorPickerSwitcher({ + value, + onChange, +}: IColorPickerSwitcherProps) { + return ( +
+ {COLOR_PICKER_VARIANTS.map((variant) => ( + + ))} +
+ ); +} diff --git a/frontend/src/app/(user)/digital-cards/components/forms/color-picker/hue-slider.tsx b/frontend/src/app/(user)/digital-cards/components/forms/color-picker/hue-slider.tsx new file mode 100644 index 00000000..0b7fd14d --- /dev/null +++ b/frontend/src/app/(user)/digital-cards/components/forms/color-picker/hue-slider.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { + hexForHue, + hueFromHex, +} from "@/app/(user)/digital-cards/utils/card-colors"; + +interface IHueSliderProps { + value: string; + onChange: (hex: string) => void; +} + +const HUE_STOPS = [0, 60, 120, 180, 240, 300, 360] + .map((hue) => `${hexForHue(hue)} ${(hue / 360) * 100}%`) + .join(", "); + +/** + * A hue slider rather than a 2D area: saturation and lightness stay locked, so every colour + * it can produce is legible under white text, and one horizontal drag is the friendliest + * possible touch target. + */ +export default function HueSlider({ value, onChange }: IHueSliderProps) { + const { hue, isNeutral } = hueFromHex(value); + + return ( + + ); +} diff --git a/frontend/src/app/(user)/digital-cards/components/forms/digital-card-fields.tsx b/frontend/src/app/(user)/digital-cards/components/forms/digital-card-fields.tsx deleted file mode 100644 index cf37cf13..00000000 --- a/frontend/src/app/(user)/digital-cards/components/forms/digital-card-fields.tsx +++ /dev/null @@ -1,123 +0,0 @@ -"use client"; - -import { useFormContext } from "react-hook-form"; - -import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; -import { - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, - FormDescription, -} from "@/components/ui/form"; -import DigitalCardSelectField from "@/app/(user)/digital-cards/components/forms/digital-card-select-field"; -import type { DigitalCardRequest } from "@/lib/api/types"; - -const CARD_TYPES = [ - { value: "loyalty", label: "Loyalty kartica" }, - { value: "discount", label: "Popust kartica" }, - { value: "membership", label: "Članstvo" }, - { value: "gift", label: "Poklon kartica" }, - { value: "other", label: "Ostalo" }, -]; - -const CODE_TYPES = [ - { value: "barcode", label: "Barkod" }, - { value: "qr", label: "QR kod" }, - { value: "number", label: "Broj" }, - { value: "text", label: "Tekst" }, -]; - -export default function DigitalCardFields() { - const form = useFormContext(); - - return ( - <> - ( - - Naziv kartice - - - - - - )} - /> - - ( - - Vrijednost/Kod - - - - - - )} - /> - - - - - - ( - - Boja kartice - - - - - - )} - /> - - ( - - Napomena - -