Compare commits
11 Commits
420fda3c6c
...
7fd0eab2c5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fd0eab2c5 | ||
|
|
84773e3363 | ||
|
|
4f7abb6b04 | ||
|
|
a0b2ffa8c4 | ||
|
|
5b765f9a7d | ||
|
|
adfb3a9fb2 | ||
|
|
b619afe24f | ||
|
|
1c2966b140 | ||
|
|
2c125b4bfd | ||
|
|
73d6e2ef82 | ||
|
|
d296c68ec1 |
15
pom.xml
15
pom.xml
@@ -27,6 +27,21 @@
|
||||
<artifactId>simple-levenshtein-distance</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.thymeleaf</groupId>
|
||||
<artifactId>thymeleaf</artifactId>
|
||||
<version>3.1.3.RELEASE</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
<version>3.2.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-thymeleaf</artifactId>
|
||||
<version>3.2.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<build>
|
||||
<plugins>
|
||||
|
||||
@@ -3,13 +3,13 @@ package nl.herpiederpiee.appie_scraper;
|
||||
import com.microsoft.playwright.ElementHandle;
|
||||
|
||||
public class BonusItem {
|
||||
String title;
|
||||
String description = "";
|
||||
String bonusText;
|
||||
String category;
|
||||
String imageURL;
|
||||
public String title;
|
||||
public String description = "";
|
||||
public String bonusText;
|
||||
public String category;
|
||||
public String imageURL;
|
||||
|
||||
String moreInfoURL;
|
||||
public String moreInfoURL;
|
||||
|
||||
float originalPrice = 0.0f;
|
||||
float bonusPrice = 0.0f;
|
||||
|
||||
@@ -3,13 +3,14 @@ package nl.herpiederpiee.appie_scraper;
|
||||
import com.microsoft.playwright.*;
|
||||
import xyz.nextn.levenshteindistance.LevenshteinDistance;
|
||||
|
||||
import java.text.Normalizer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class BonusManager {
|
||||
ArrayList<BonusItem> bonusItems = new ArrayList<BonusItem>();;
|
||||
static ArrayList<BonusItem> bonusItems = new ArrayList<BonusItem>();;
|
||||
|
||||
public void updateBonusItems(){
|
||||
public static void updateBonusItems(){
|
||||
try (Playwright playwright = Playwright.create()) {
|
||||
|
||||
Browser browser = playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(false));
|
||||
@@ -31,7 +32,7 @@ public class BonusManager {
|
||||
if (bonusItem.category.equals( "gall-card")) continue;
|
||||
if (bonusItem.category.equals( "etos")) continue;
|
||||
|
||||
this.bonusItems.add(bonusItem);
|
||||
bonusItems.add(bonusItem);
|
||||
}
|
||||
|
||||
} catch (InterruptedException e) {
|
||||
@@ -39,15 +40,22 @@ public class BonusManager {
|
||||
}
|
||||
}
|
||||
|
||||
public ArrayList<BonusItem> getBonusItems(String name){
|
||||
public static ArrayList<BonusItem> getBonusItems(String name){
|
||||
ArrayList<Pair<BonusItem, Integer>> list = new ArrayList<>();
|
||||
|
||||
if (name == null || name.trim().isEmpty()){
|
||||
return bonusItems;
|
||||
}
|
||||
|
||||
for (BonusItem bonusItem : bonusItems) {
|
||||
Integer score = fuzzyMatchScore(name, bonusItem.title);
|
||||
list.add(Pair.pair(bonusItem, score));
|
||||
}
|
||||
list.sort((a, b) -> Integer.compare(b.second, a.second));
|
||||
|
||||
if (list.get(0).second.equals(0)){
|
||||
return new ArrayList<>();
|
||||
}
|
||||
ArrayList<BonusItem> top10 = new ArrayList<>();
|
||||
int i = 0;
|
||||
while (top10.size() < 10) {
|
||||
@@ -58,26 +66,153 @@ public class BonusManager {
|
||||
return top10;
|
||||
}
|
||||
|
||||
public int fuzzyMatchScore(String query, String title) {
|
||||
query = query.toLowerCase();
|
||||
title = title.toLowerCase();
|
||||
|
||||
if (title.contains(query)) {
|
||||
return 100; // perfect match
|
||||
public static int fuzzyMatchScore(String query, String title) {
|
||||
if (query == null || title == null || query.isEmpty() || title.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int best = Integer.MAX_VALUE;
|
||||
String normalizedQuery = normalize(query);
|
||||
String normalizedTitle = normalize(title);
|
||||
|
||||
// ===== TIER 1: EXACT WORD MATCH (highest priority) =====
|
||||
if (isExactWordMatch(normalizedTitle, normalizedQuery)) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
// ===== TIER 2: WORD-BOUNDARY SUBSTRING =====
|
||||
if (isWordBoundaryMatch(normalizedTitle, normalizedQuery)) {
|
||||
return 95;
|
||||
}
|
||||
|
||||
// ===== TIER 3: PREFIX MATCH =====
|
||||
if (isPrefixMatch(normalizedTitle, normalizedQuery)) {
|
||||
return 85;
|
||||
}
|
||||
|
||||
// ===== TIER 4: LEVENSHTEIN (typo tolerance) =====
|
||||
int qlen = normalizedQuery.length();
|
||||
int tlen = normalizedTitle.length();
|
||||
|
||||
if (qlen > tlen) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int bestDistance = Integer.MAX_VALUE;
|
||||
|
||||
for (int i = 0; i <= tlen - qlen; i++) {
|
||||
String sub = normalizedTitle.substring(i, i + qlen);
|
||||
int dist = levenshteinDistance(normalizedQuery, sub);
|
||||
if (dist < bestDistance) {
|
||||
bestDistance = dist;
|
||||
if (dist == 0) break;
|
||||
}
|
||||
}
|
||||
|
||||
// Allow up to 2 edits (typo tolerance)
|
||||
if (bestDistance <= 2) {
|
||||
// Distance 0 = 80, Distance 1 = 70, Distance 2 = 60
|
||||
int score = 80 - (bestDistance * 10);
|
||||
return Math.max(0, score);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact word match: query must be surrounded by word boundaries or string edges
|
||||
* "LOR" matches "L OR" or "LOR coffee" but NOT "LOREAL"
|
||||
*/
|
||||
private static boolean isExactWordMatch(String title, String query) {
|
||||
String[] words = title.split("\\s+");
|
||||
for (String word : words) {
|
||||
if (word.equals(query)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Word boundary match: query matches at word start/end
|
||||
* "LOR" matches in "L'OR" (after special char removed)
|
||||
* "REAL" matches in "LOREAL" as word boundary? No, stays in Tier 4
|
||||
*/
|
||||
private static boolean isWordBoundaryMatch(String title, String query) {
|
||||
// Check if query appears after space or at start
|
||||
if (title.startsWith(query + " ")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (title.contains(" " + query)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if query ends at word boundary
|
||||
if (title.endsWith(" " + query)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefix match: query is the start of any word
|
||||
* "CAF" matches in "CAFFE" or "CAFE LATTE"
|
||||
*/
|
||||
private static boolean isPrefixMatch(String title, String query) {
|
||||
for (String word : title.split("\\s+")) {
|
||||
if (word.startsWith(query) && word.length() > query.length()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String normalize(String input) {
|
||||
if (input == null || input.isEmpty()) {
|
||||
return input;
|
||||
}
|
||||
|
||||
// Remove diacritics
|
||||
String decomposed = Normalizer.normalize(input, Normalizer.Form.NFD);
|
||||
String withoutDiacritics = decomposed
|
||||
.replaceAll("\\p{InCombiningDiacriticalMarks}+", "");
|
||||
|
||||
// Lowercase, remove special chars, normalize spaces
|
||||
String cleaned = withoutDiacritics
|
||||
.toLowerCase()
|
||||
.replaceAll("[^a-z0-9\\s]", "")
|
||||
.replaceAll("\\s+", " ")
|
||||
.trim();
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private static int levenshteinDistance(String query, String title) {
|
||||
int qlen = query.length();
|
||||
int tlen = title.length();
|
||||
|
||||
for (int i = 0; i <= tlen - qlen; i++) {
|
||||
String sub = title.substring(i, i + qlen);
|
||||
int dist = LevenshteinDistance.calculate(query, sub);
|
||||
if (dist < best) best = dist;
|
||||
if (qlen == 0) return tlen;
|
||||
if (tlen == 0) return qlen;
|
||||
|
||||
int[][] dp = new int[qlen + 1][tlen + 1];
|
||||
|
||||
for (int i = 0; i <= qlen; i++) dp[i][0] = i;
|
||||
for (int j = 0; j <= tlen; j++) dp[0][j] = j;
|
||||
|
||||
for (int i = 1; i <= qlen; i++) {
|
||||
for (int j = 1; j <= tlen; j++) {
|
||||
if (query.charAt(i - 1) == title.charAt(j - 1)) {
|
||||
dp[i][j] = dp[i - 1][j - 1];
|
||||
} else {
|
||||
dp[i][j] = 1 + Math.min(
|
||||
Math.min(dp[i - 1][j - 1], dp[i - 1][j]),
|
||||
dp[i][j - 1]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int score = (int)(100.0 * (1.0 - (best / (double) qlen))); // fancy manier om t naar een % match om te zetten
|
||||
return Math.max(0, Math.min(100, score));
|
||||
return dp[qlen][tlen];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,29 @@
|
||||
package nl.herpiederpiee.appie_scraper;
|
||||
|
||||
import com.microsoft.playwright.*;
|
||||
import org.json.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Scanner;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
|
||||
@SpringBootApplication
|
||||
public class Main {
|
||||
|
||||
|
||||
public static void main(String[] args) {
|
||||
BonusManager bonusManager = new BonusManager();
|
||||
bonusManager.updateBonusItems();
|
||||
|
||||
Scanner input = new Scanner(System.in);
|
||||
BonusManager.updateBonusItems();
|
||||
|
||||
|
||||
// start the webserver
|
||||
SpringApplication.run(Main.class, args);
|
||||
|
||||
while (true) {
|
||||
System.out.println("\n\nWhat item would you like to look for?");
|
||||
String userInput = input.nextLine();
|
||||
if (userInput.equals("qqq")) break;
|
||||
ArrayList<BonusItem> userResults = bonusManager.getBonusItems(userInput);
|
||||
|
||||
for (BonusItem bonusItem : userResults) {
|
||||
System.out.println(bonusItem.title + " => " + bonusItem.bonusText);
|
||||
}
|
||||
}
|
||||
// while (true) {
|
||||
// System.out.println("\n\nWhat item would you like to look for?");
|
||||
// String userInput = input.nextLine();
|
||||
// if (userInput.equals("qqq")) break;
|
||||
// ArrayList<BonusItem> userResults = BonusManager.getBonusItems(userInput);
|
||||
//
|
||||
// for (BonusItem bonusItem : userResults) {
|
||||
// System.out.println(bonusItem.title + " => " + bonusItem.bonusText);
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
25
src/main/java/nl/herpiederpiee/appie_scraper/WebServer.java
Normal file
25
src/main/java/nl/herpiederpiee/appie_scraper/WebServer.java
Normal file
@@ -0,0 +1,25 @@
|
||||
package nl.herpiederpiee.appie_scraper;
|
||||
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
@Controller
|
||||
public class WebServer {
|
||||
|
||||
@GetMapping("/")
|
||||
public String index() {
|
||||
return "index";
|
||||
}
|
||||
|
||||
@GetMapping("/api/fuzzy")
|
||||
@ResponseBody
|
||||
public ArrayList<BonusItem> fuzzySearch(@RequestParam(value = "q", required = false, defaultValue = "") String fuzzySearch){
|
||||
ArrayList<BonusItem> items = BonusManager.getBonusItems(fuzzySearch);
|
||||
return items;
|
||||
}
|
||||
}
|
||||
2
src/main/resources/application.properties
Normal file
2
src/main/resources/application.properties
Normal file
@@ -0,0 +1,2 @@
|
||||
server.port=9823
|
||||
spring.application.name=AppieBonusScraper
|
||||
508
src/main/resources/templates/index.html
Normal file
508
src/main/resources/templates/index.html
Normal file
@@ -0,0 +1,508 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Appie Scraper</title>
|
||||
<style>
|
||||
:root {
|
||||
--ah-blue: #00ADE6;
|
||||
--ah-blue-dark: #0091B8;
|
||||
--ah-blue-light: #1FC4F0;
|
||||
--ah-orange: #FF7900;
|
||||
--ah-dark-bg: #0D1117;
|
||||
--ah-dark-surface: #161B22;
|
||||
--ah-dark-surface-light: #21262D;
|
||||
--ah-text: #E6EDF3;
|
||||
--ah-text-secondary: #8B949E;
|
||||
--ah-border: #30363D;
|
||||
--spacing-xs: 8px;
|
||||
--spacing-sm: 12px;
|
||||
--spacing-md: 16px;
|
||||
--spacing-lg: 24px;
|
||||
--spacing-xl: 32px;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: linear-gradient(135deg, var(--ah-dark-bg) 0%, #132236 100%);
|
||||
color: var(--ah-text);
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
header {
|
||||
background: linear-gradient(90deg, var(--ah-dark-surface) 0%, #1a2634 100%);
|
||||
padding: var(--spacing-lg);
|
||||
border-bottom: 3px solid var(--ah-blue);
|
||||
box-shadow: 0 8px 32px rgba(0, 173, 230, 0.15);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(24px, 5vw, 36px);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
background: linear-gradient(135deg, var(--ah-blue) 0%, var(--ah-blue-light) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--spacing-lg);
|
||||
}
|
||||
|
||||
.search-container {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
animation: slideDown 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
padding: var(--spacing-md);
|
||||
font-size: 15px;
|
||||
border: 2px solid var(--ah-border);
|
||||
border-radius: 10px;
|
||||
background: var(--ah-dark-surface-light);
|
||||
color: var(--ah-text);
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
input[type="text"]::placeholder {
|
||||
color: var(--ah-text-secondary);
|
||||
}
|
||||
|
||||
input[type="text"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--ah-blue);
|
||||
box-shadow: 0 0 0 3px rgba(0, 173, 230, 0.25), 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.search-info {
|
||||
font-size: 13px;
|
||||
color: var(--ah-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
animation: fadeIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--ah-dark-surface);
|
||||
border: 1px solid var(--ah-border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 32px rgba(0, 173, 230, 0.2);
|
||||
border-color: var(--ah-blue);
|
||||
}
|
||||
|
||||
.card-image {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
background: linear-gradient(135deg, var(--ah-dark-surface-light) 0%, var(--ah-dark-bg) 100%);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.card:hover .card-image {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: var(--spacing-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--ah-text);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.card-description {
|
||||
font-size: 13px;
|
||||
color: var(--ah-text-secondary);
|
||||
line-height: 1.5;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card-bonus {
|
||||
display: inline-block;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: linear-gradient(135deg, rgba(255, 121, 0, 0.15), rgba(255, 121, 0, 0.05));
|
||||
border: 1px solid rgba(255, 121, 0, 0.3);
|
||||
border-radius: 6px;
|
||||
color: var(--ah-orange);
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: var(--spacing-md);
|
||||
border-top: 1px solid var(--ah-border);
|
||||
gap: var(--spacing-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-category {
|
||||
font-size: 11px;
|
||||
background: rgba(0, 173, 230, 0.1);
|
||||
color: var(--ah-blue-light);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.card-link {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--ah-blue-light);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
background: rgba(0, 173, 230, 0.1);
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
background: rgba(0, 173, 230, 0.2);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.no-results {
|
||||
text-align: center;
|
||||
padding: var(--spacing-xl);
|
||||
color: var(--ah-text-secondary);
|
||||
font-size: 16px;
|
||||
background: var(--ah-dark-surface);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--ah-border);
|
||||
}
|
||||
|
||||
.no-results strong {
|
||||
color: var(--ah-orange);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--ah-dark-bg);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--ah-blue);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--ah-blue-light);
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 768px) {
|
||||
:root {
|
||||
--spacing-xs: 6px;
|
||||
--spacing-sm: 10px;
|
||||
--spacing-md: 12px;
|
||||
--spacing-lg: 16px;
|
||||
--spacing-xl: 20px;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 0 var(--spacing-md);
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
font-size: 16px; /* Prevents zoom on iOS */
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
}
|
||||
|
||||
.cards-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.card-image {
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: var(--spacing-md);
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.card-description {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.card-link {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: var(--spacing-sm);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
header {
|
||||
padding: var(--spacing-sm);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 0 var(--spacing-sm);
|
||||
}
|
||||
|
||||
.cards-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.card-image {
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.card-description {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.card-bonus {
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
padding: 10px 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading skeleton */
|
||||
.card-skeleton {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.6; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="container">
|
||||
<h1>🛒 Appie Bonus Tracker</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<div class="search-container">
|
||||
<div class="search-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
id="fuzzySearch"
|
||||
placeholder="Search bonus items... (real-time)"
|
||||
autocomplete="off"
|
||||
>
|
||||
<div class="search-info">
|
||||
<span id="resultCount">Loading items...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="cardsContainer" class="cards-grid">
|
||||
<!-- Cards will be inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const searchInput = document.getElementById('fuzzySearch');
|
||||
const cardsContainer = document.getElementById('cardsContainer');
|
||||
const resultCount = document.getElementById('resultCount');
|
||||
let debounceTimer;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
performFuzzySearch('');
|
||||
});
|
||||
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
clearTimeout(debounceTimer);
|
||||
const query = e.target.value.trim();
|
||||
|
||||
debounceTimer = setTimeout(() => {
|
||||
performFuzzySearch(query);
|
||||
}, 300);
|
||||
});
|
||||
|
||||
async function performFuzzySearch(query) {
|
||||
try {
|
||||
const response = await fetch(`/api/fuzzy?q=${encodeURIComponent(query)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Search failed:', response.status);
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await response.json();
|
||||
renderResults(results, query);
|
||||
} catch (error) {
|
||||
console.error('Error performing search:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function renderResults(results, query) {
|
||||
if (results.length === 0) {
|
||||
cardsContainer.innerHTML = `
|
||||
<div style="grid-column: 1 / -1;">
|
||||
<div class="no-results">
|
||||
No items found matching "<strong>${escapeHtml(query)}</strong>"
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
resultCount.textContent = `0 results for "${query}"`;
|
||||
return;
|
||||
}
|
||||
|
||||
cardsContainer.innerHTML = results.map(item => `
|
||||
<div class="card">
|
||||
<img src="${escapeHtml(item.imageURL)}" alt="${escapeHtml(item.title)}" class="card-image">
|
||||
<div class="card-content">
|
||||
<h2 class="card-title">${escapeHtml(item.title)}</h2>
|
||||
<p class="card-description">${escapeHtml(item.description)}</p>
|
||||
<div class="card-bonus">${escapeHtml(item.bonusText)}</div>
|
||||
<div class="card-meta">
|
||||
<span class="card-category">${escapeHtml(item.category)}</span>
|
||||
<a href="${escapeHtml(item.moreInfoURL)}" target="_blank" class="card-link">View Offer</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
resultCount.textContent = `${results.length} item${results.length !== 1 ? 's' : ''} found`;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const map = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
return text.replace(/[&<>"']/g, m => map[m]);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user