Compare commits
27 Commits
68ac857091
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 467bd88f95 | |||
| d75a30f459 | |||
| 06fa208dd8 | |||
| 883f76e488 | |||
| d8a9e9fd0a | |||
| 5c00be59f1 | |||
| 004698b604 | |||
| 096daffb3e | |||
| b179a88d4c | |||
| 2bf0ac0f3e | |||
| bb9a78f755 | |||
| 45f562e2ce | |||
| 5914ef2ac8 | |||
| 948c70c7f2 | |||
| 3c02d33531 | |||
| 6320cb14e5 | |||
| f9192f2228 | |||
| ef64c89e9b | |||
| c047966b43 | |||
| 169fa0ad63 | |||
| 886e5c121f | |||
| ef2b9dfabf | |||
| 489b975911 | |||
| 1d02079df3 | |||
| d109dfad0e | |||
| 9ba0825bcd | |||
| 83e852e5fb |
2
.semgrepignore
Normal file
2
.semgrepignore
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Ignore console.log formatting warnings
|
||||||
|
javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring
|
||||||
1
.vscode/settings.json
vendored
Normal file
1
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
6
Logs/cpu.log
Normal file
6
Logs/cpu.log
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
1/6 22:21:38.843 vendor: AuthenticAMD
|
||||||
|
1/6 22:21:38.843 branding: AMD Ryzen 7 5700X3D 8-Core Processor
|
||||||
|
1/6 22:21:38.843 features: lahf64 cmpxchg16b sse sse2 sse3 ssse3 sse41 sse42 avx avx2 aesni clmul sha rdrand
|
||||||
|
1/6 22:21:38.843 sockets: 1
|
||||||
|
1/6 22:21:38.843 cores: 8
|
||||||
|
1/6 22:21:38.843 threads: 16
|
||||||
5
add_flavor_radar_to_tastings.sql
Normal file
5
add_flavor_radar_to_tastings.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
-- Add flavor_profile column to tastings table
|
||||||
|
ALTER TABLE public.tastings
|
||||||
|
ADD COLUMN IF NOT EXISTS flavor_profile JSONB;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN public.tastings.flavor_profile IS 'Stores radar chart scores for smoky, fruity, spicy, sweet, and floral (0-100).';
|
||||||
@@ -160,7 +160,7 @@
|
|||||||
"oily",
|
"oily",
|
||||||
"medium-full body",
|
"medium-full body",
|
||||||
"silky",
|
"silky",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"balanced",
|
"balanced",
|
||||||
"smooth",
|
"smooth",
|
||||||
"slightly warming",
|
"slightly warming",
|
||||||
@@ -774,7 +774,7 @@
|
|||||||
],
|
],
|
||||||
"texture": [
|
"texture": [
|
||||||
"oily and waxy",
|
"oily and waxy",
|
||||||
"creamy and rounded",
|
"creamy and rounded-sm",
|
||||||
"medium weight",
|
"medium weight",
|
||||||
"silky with a gentle grip",
|
"silky with a gentle grip",
|
||||||
"well-structured balancing sweetness and dryness",
|
"well-structured balancing sweetness and dryness",
|
||||||
@@ -880,7 +880,7 @@
|
|||||||
"oily",
|
"oily",
|
||||||
"viscous",
|
"viscous",
|
||||||
"silky",
|
"silky",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"balanced",
|
"balanced",
|
||||||
"medium-bodied",
|
"medium-bodied",
|
||||||
"slightly-dry",
|
"slightly-dry",
|
||||||
@@ -1038,7 +1038,7 @@
|
|||||||
"polished",
|
"polished",
|
||||||
"well-integrated",
|
"well-integrated",
|
||||||
"slightly oily",
|
"slightly oily",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"smooth",
|
"smooth",
|
||||||
"lively"
|
"lively"
|
||||||
]
|
]
|
||||||
@@ -1235,7 +1235,7 @@
|
|||||||
"softly effervescent",
|
"softly effervescent",
|
||||||
"polished oak feel",
|
"polished oak feel",
|
||||||
"refreshingly bright",
|
"refreshingly bright",
|
||||||
"lean yet rounded"
|
"lean yet rounded-sm"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Aultmore": {
|
"Aultmore": {
|
||||||
@@ -1527,7 +1527,7 @@
|
|||||||
"creamy",
|
"creamy",
|
||||||
"silky",
|
"silky",
|
||||||
"oily",
|
"oily",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"polished",
|
"polished",
|
||||||
"mellow",
|
"mellow",
|
||||||
"gentle",
|
"gentle",
|
||||||
@@ -1588,7 +1588,7 @@
|
|||||||
"Lingering sea-salt",
|
"Lingering sea-salt",
|
||||||
"Sweet lemon",
|
"Sweet lemon",
|
||||||
"Lime zest",
|
"Lime zest",
|
||||||
"Dried apple ring",
|
"Dried apple ring-3",
|
||||||
"Vanilla oak",
|
"Vanilla oak",
|
||||||
"Gentle oak tannin",
|
"Gentle oak tannin",
|
||||||
"Black pepper",
|
"Black pepper",
|
||||||
@@ -1883,7 +1883,7 @@
|
|||||||
"polished",
|
"polished",
|
||||||
"slightly resinous",
|
"slightly resinous",
|
||||||
"lively oak spice",
|
"lively oak spice",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"structured",
|
"structured",
|
||||||
"sappy (young oak feel)"
|
"sappy (young oak feel)"
|
||||||
]
|
]
|
||||||
@@ -2001,7 +2001,7 @@
|
|||||||
"oily",
|
"oily",
|
||||||
"creamy",
|
"creamy",
|
||||||
"silky",
|
"silky",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"medium-bodied",
|
"medium-bodied",
|
||||||
"polished",
|
"polished",
|
||||||
"gently spirity",
|
"gently spirity",
|
||||||
@@ -2156,7 +2156,7 @@
|
|||||||
"oily",
|
"oily",
|
||||||
"creamy",
|
"creamy",
|
||||||
"medium to full-bodied",
|
"medium to full-bodied",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"silky",
|
"silky",
|
||||||
"slightly waxy",
|
"slightly waxy",
|
||||||
"balanced",
|
"balanced",
|
||||||
@@ -2415,7 +2415,7 @@
|
|||||||
"grippy",
|
"grippy",
|
||||||
"structured tannins",
|
"structured tannins",
|
||||||
"cask-driven",
|
"cask-driven",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"balanced",
|
"balanced",
|
||||||
"robust",
|
"robust",
|
||||||
"powerful",
|
"powerful",
|
||||||
@@ -2544,7 +2544,7 @@
|
|||||||
"waxy",
|
"waxy",
|
||||||
"silky",
|
"silky",
|
||||||
"rich",
|
"rich",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"medium-to-full bodied",
|
"medium-to-full bodied",
|
||||||
"coating",
|
"coating",
|
||||||
"smooth",
|
"smooth",
|
||||||
@@ -2781,7 +2781,7 @@
|
|||||||
"light-to-medium body",
|
"light-to-medium body",
|
||||||
"soft",
|
"soft",
|
||||||
"polished",
|
"polished",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"well-structured",
|
"well-structured",
|
||||||
"approachable",
|
"approachable",
|
||||||
"gentle",
|
"gentle",
|
||||||
@@ -2868,7 +2868,7 @@
|
|||||||
"creamy toffee and caramelized sugar",
|
"creamy toffee and caramelized sugar",
|
||||||
"vanilla oak spicing (cinnamon, white pepper)",
|
"vanilla oak spicing (cinnamon, white pepper)",
|
||||||
"nutty undertones (almond, hazelnut)",
|
"nutty undertones (almond, hazelnut)",
|
||||||
"soft rounded bitterness (cocoa nibs, orange marmalade)",
|
"soft rounded-sm bitterness (cocoa nibs, orange marmalade)",
|
||||||
"mild stone fruit (apricot) and dried sultana",
|
"mild stone fruit (apricot) and dried sultana",
|
||||||
"waxy orchard skin texture and gentle oiliness",
|
"waxy orchard skin texture and gentle oiliness",
|
||||||
"cereal maltiness with a slight biscuit edge",
|
"cereal maltiness with a slight biscuit edge",
|
||||||
@@ -2888,7 +2888,7 @@
|
|||||||
],
|
],
|
||||||
"texture": [
|
"texture": [
|
||||||
"waxy mouthfeel that softens with age",
|
"waxy mouthfeel that softens with age",
|
||||||
"creamy and rounded",
|
"creamy and rounded-sm",
|
||||||
"oily yet clean, not heavy",
|
"oily yet clean, not heavy",
|
||||||
"slightly coating but not syrupy",
|
"slightly coating but not syrupy",
|
||||||
"polished oak structure underneath",
|
"polished oak structure underneath",
|
||||||
@@ -2992,7 +2992,7 @@
|
|||||||
"polished oak",
|
"polished oak",
|
||||||
"soft tannins",
|
"soft tannins",
|
||||||
"gentle heat",
|
"gentle heat",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"clean",
|
"clean",
|
||||||
"crisp",
|
"crisp",
|
||||||
"gliding",
|
"gliding",
|
||||||
@@ -3591,7 +3591,7 @@
|
|||||||
"silky",
|
"silky",
|
||||||
"medium-bodied",
|
"medium-bodied",
|
||||||
"slightly oily",
|
"slightly oily",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"well-integrated alcohol",
|
"well-integrated alcohol",
|
||||||
"polished tannins",
|
"polished tannins",
|
||||||
"sprightly",
|
"sprightly",
|
||||||
@@ -3820,7 +3820,7 @@
|
|||||||
"viscous",
|
"viscous",
|
||||||
"creamy",
|
"creamy",
|
||||||
"velvety",
|
"velvety",
|
||||||
"rounded mouthfeel",
|
"rounded-sm mouthfeel",
|
||||||
"slightly chewy",
|
"slightly chewy",
|
||||||
"resinous",
|
"resinous",
|
||||||
"warming",
|
"warming",
|
||||||
@@ -4776,7 +4776,7 @@
|
|||||||
"silky",
|
"silky",
|
||||||
"oily",
|
"oily",
|
||||||
"creamy",
|
"creamy",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"balanced",
|
"balanced",
|
||||||
"medium-bodied",
|
"medium-bodied",
|
||||||
"spry",
|
"spry",
|
||||||
@@ -5066,7 +5066,7 @@
|
|||||||
"well-balanced",
|
"well-balanced",
|
||||||
"medium-to-full body",
|
"medium-to-full body",
|
||||||
"coating",
|
"coating",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"polished",
|
"polished",
|
||||||
"lively",
|
"lively",
|
||||||
"gentle",
|
"gentle",
|
||||||
@@ -5424,7 +5424,7 @@
|
|||||||
"smooth",
|
"smooth",
|
||||||
"creamy",
|
"creamy",
|
||||||
"soft",
|
"soft",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"medium-bodied",
|
"medium-bodied",
|
||||||
"polished",
|
"polished",
|
||||||
"well-balanced",
|
"well-balanced",
|
||||||
@@ -5602,7 +5602,7 @@
|
|||||||
"quiet floral echo (heather)"
|
"quiet floral echo (heather)"
|
||||||
],
|
],
|
||||||
"texture": [
|
"texture": [
|
||||||
"creamy and rounded",
|
"creamy and rounded-sm",
|
||||||
"silky with gentle oiliness",
|
"silky with gentle oiliness",
|
||||||
"medium body, never heavy",
|
"medium body, never heavy",
|
||||||
"polished oak grip (fine-grained tannins)",
|
"polished oak grip (fine-grained tannins)",
|
||||||
@@ -5861,7 +5861,7 @@
|
|||||||
"oily",
|
"oily",
|
||||||
"creamy",
|
"creamy",
|
||||||
"velvety",
|
"velvety",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"medium-bodied",
|
"medium-bodied",
|
||||||
"approachable",
|
"approachable",
|
||||||
"slightly waxy",
|
"slightly waxy",
|
||||||
@@ -6002,7 +6002,7 @@
|
|||||||
"smooth",
|
"smooth",
|
||||||
"silky",
|
"silky",
|
||||||
"slightly waxy",
|
"slightly waxy",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"gentle",
|
"gentle",
|
||||||
"well-balanced",
|
"well-balanced",
|
||||||
"not overly viscous",
|
"not overly viscous",
|
||||||
@@ -6070,7 +6070,7 @@
|
|||||||
"silky",
|
"silky",
|
||||||
"oily (light)",
|
"oily (light)",
|
||||||
"soft",
|
"soft",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"clean",
|
"clean",
|
||||||
"crisp",
|
"crisp",
|
||||||
"approachable",
|
"approachable",
|
||||||
@@ -6230,7 +6230,7 @@
|
|||||||
"waxy",
|
"waxy",
|
||||||
"silky",
|
"silky",
|
||||||
"medium-weight",
|
"medium-weight",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"well-structured",
|
"well-structured",
|
||||||
"balanced",
|
"balanced",
|
||||||
"clean",
|
"clean",
|
||||||
@@ -6427,7 +6427,7 @@
|
|||||||
"waxy-coated",
|
"waxy-coated",
|
||||||
"chalky/mineral grip",
|
"chalky/mineral grip",
|
||||||
"well-structured",
|
"well-structured",
|
||||||
"rounded and balanced",
|
"rounded-sm and balanced",
|
||||||
"smooth entry",
|
"smooth entry",
|
||||||
"zesty lift",
|
"zesty lift",
|
||||||
"slightly drying oak"
|
"slightly drying oak"
|
||||||
@@ -6507,7 +6507,7 @@
|
|||||||
"waxy",
|
"waxy",
|
||||||
"silky",
|
"silky",
|
||||||
"medium-bodied",
|
"medium-bodied",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"slightly resinous",
|
"slightly resinous",
|
||||||
"prickly spice",
|
"prickly spice",
|
||||||
"chewy",
|
"chewy",
|
||||||
@@ -7021,7 +7021,7 @@
|
|||||||
"oily",
|
"oily",
|
||||||
"waxy",
|
"waxy",
|
||||||
"coastal mouthfeel",
|
"coastal mouthfeel",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"balanced",
|
"balanced",
|
||||||
"creamy",
|
"creamy",
|
||||||
"silky",
|
"silky",
|
||||||
@@ -7265,7 +7265,7 @@
|
|||||||
"smooth and approachable",
|
"smooth and approachable",
|
||||||
"medium-bodied",
|
"medium-bodied",
|
||||||
"slightly oily",
|
"slightly oily",
|
||||||
"soft and rounded",
|
"soft and rounded-sm",
|
||||||
"creamy",
|
"creamy",
|
||||||
"well-integrated alcohol",
|
"well-integrated alcohol",
|
||||||
"gentle spice",
|
"gentle spice",
|
||||||
@@ -7648,7 +7648,7 @@
|
|||||||
"Slightly prickly",
|
"Slightly prickly",
|
||||||
"Weighty yet agile",
|
"Weighty yet agile",
|
||||||
"Chewy",
|
"Chewy",
|
||||||
"Soft and rounded",
|
"Soft and rounded-sm",
|
||||||
"Peppery heat"
|
"Peppery heat"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -7949,7 +7949,7 @@
|
|||||||
"waxy / coating",
|
"waxy / coating",
|
||||||
"oily",
|
"oily",
|
||||||
"medium-bodied",
|
"medium-bodied",
|
||||||
"softly rounded",
|
"softly rounded-sm",
|
||||||
"creamy",
|
"creamy",
|
||||||
"silky",
|
"silky",
|
||||||
"slightly drying",
|
"slightly drying",
|
||||||
@@ -8003,7 +8003,7 @@
|
|||||||
"oily and resinous",
|
"oily and resinous",
|
||||||
"creamy and luscious",
|
"creamy and luscious",
|
||||||
"viscous mouthfeel",
|
"viscous mouthfeel",
|
||||||
"rounded and polished",
|
"rounded-sm and polished",
|
||||||
"silky with grip",
|
"silky with grip",
|
||||||
"balanced warmth",
|
"balanced warmth",
|
||||||
"velvety oak",
|
"velvety oak",
|
||||||
@@ -8145,7 +8145,7 @@
|
|||||||
"silky",
|
"silky",
|
||||||
"creamy",
|
"creamy",
|
||||||
"oily",
|
"oily",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"plush",
|
"plush",
|
||||||
"well-integrated",
|
"well-integrated",
|
||||||
"velvety",
|
"velvety",
|
||||||
@@ -8258,12 +8258,12 @@
|
|||||||
"dry, gently cereal/biscuity tail",
|
"dry, gently cereal/biscuity tail",
|
||||||
"clean, crisp acidity (a touch of citrus)",
|
"clean, crisp acidity (a touch of citrus)",
|
||||||
"overall dryness in later stages",
|
"overall dryness in later stages",
|
||||||
"no sulphur, very smooth and rounded"
|
"no sulphur, very smooth and rounded-sm"
|
||||||
],
|
],
|
||||||
"texture": [
|
"texture": [
|
||||||
"smooth and approachable",
|
"smooth and approachable",
|
||||||
"medium-bodied",
|
"medium-bodied",
|
||||||
"creamy and rounded",
|
"creamy and rounded-sm",
|
||||||
"silky and polished",
|
"silky and polished",
|
||||||
"slightly oily in the glass but light on the palate",
|
"slightly oily in the glass but light on the palate",
|
||||||
"well-balanced",
|
"well-balanced",
|
||||||
@@ -8319,7 +8319,7 @@
|
|||||||
"smooth with a gentle prickle",
|
"smooth with a gentle prickle",
|
||||||
"soft and approachable",
|
"soft and approachable",
|
||||||
"salty tactile impression",
|
"salty tactile impression",
|
||||||
"rounded oak structure without heaviness",
|
"rounded-sm oak structure without heaviness",
|
||||||
"clean, brisk progression across the palate"
|
"clean, brisk progression across the palate"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -8758,7 +8758,7 @@
|
|||||||
"silky",
|
"silky",
|
||||||
"slightly chewy",
|
"slightly chewy",
|
||||||
"polished",
|
"polished",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"balanced",
|
"balanced",
|
||||||
"approachable",
|
"approachable",
|
||||||
"maritime grip",
|
"maritime grip",
|
||||||
@@ -9322,7 +9322,7 @@
|
|||||||
"creamy",
|
"creamy",
|
||||||
"oily",
|
"oily",
|
||||||
"waxy",
|
"waxy",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"smooth",
|
"smooth",
|
||||||
"soft",
|
"soft",
|
||||||
"mouth-coating",
|
"mouth-coating",
|
||||||
@@ -9687,7 +9687,7 @@
|
|||||||
"polished",
|
"polished",
|
||||||
"slightly oily",
|
"slightly oily",
|
||||||
"creamy",
|
"creamy",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"well-balanced",
|
"well-balanced",
|
||||||
"slightly drying",
|
"slightly drying",
|
||||||
"crisp",
|
"crisp",
|
||||||
@@ -9850,7 +9850,7 @@
|
|||||||
"silky",
|
"silky",
|
||||||
"creamy",
|
"creamy",
|
||||||
"velvety",
|
"velvety",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"medium-bodied",
|
"medium-bodied",
|
||||||
"oily",
|
"oily",
|
||||||
"polished",
|
"polished",
|
||||||
@@ -9919,7 +9919,7 @@
|
|||||||
"Clean and medium length",
|
"Clean and medium length",
|
||||||
"Subtle coconut",
|
"Subtle coconut",
|
||||||
"A touch of herbal freshness",
|
"A touch of herbal freshness",
|
||||||
"Smooth and rounded close",
|
"Smooth and rounded-sm close",
|
||||||
"Fading floral note"
|
"Fading floral note"
|
||||||
],
|
],
|
||||||
"texture": [
|
"texture": [
|
||||||
@@ -9929,7 +9929,7 @@
|
|||||||
"Soft and approachable",
|
"Soft and approachable",
|
||||||
"Slightly oily",
|
"Slightly oily",
|
||||||
"Polished",
|
"Polished",
|
||||||
"Even and rounded",
|
"Even and rounded-sm",
|
||||||
"Mellow",
|
"Mellow",
|
||||||
"Clean and fresh",
|
"Clean and fresh",
|
||||||
"Non-aggressive"
|
"Non-aggressive"
|
||||||
@@ -10140,7 +10140,7 @@
|
|||||||
"creamy",
|
"creamy",
|
||||||
"well-integrated alcohol",
|
"well-integrated alcohol",
|
||||||
"polished oak influence",
|
"polished oak influence",
|
||||||
"soft and rounded",
|
"soft and rounded-sm",
|
||||||
"gently warming",
|
"gently warming",
|
||||||
"bright and lively",
|
"bright and lively",
|
||||||
"clean and crisp"
|
"clean and crisp"
|
||||||
@@ -10267,7 +10267,7 @@
|
|||||||
"prickly pepper",
|
"prickly pepper",
|
||||||
"well-integrated heat",
|
"well-integrated heat",
|
||||||
"chewy",
|
"chewy",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"silky",
|
"silky",
|
||||||
"moderately weighted"
|
"moderately weighted"
|
||||||
]
|
]
|
||||||
@@ -10409,7 +10409,7 @@
|
|||||||
"medium-bodied",
|
"medium-bodied",
|
||||||
"well-integrated alcohol",
|
"well-integrated alcohol",
|
||||||
"polished oak",
|
"polished oak",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"luscious",
|
"luscious",
|
||||||
"slightly viscous",
|
"slightly viscous",
|
||||||
"smooth"
|
"smooth"
|
||||||
@@ -10749,7 +10749,7 @@
|
|||||||
"thick",
|
"thick",
|
||||||
"viscous",
|
"viscous",
|
||||||
"mouth-coating",
|
"mouth-coating",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"well-integrated",
|
"well-integrated",
|
||||||
"polished",
|
"polished",
|
||||||
"slightly drying",
|
"slightly drying",
|
||||||
@@ -10845,7 +10845,7 @@
|
|||||||
"smooth",
|
"smooth",
|
||||||
"fresh",
|
"fresh",
|
||||||
"sprightly",
|
"sprightly",
|
||||||
"rounded"
|
"rounded-sm"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Jameson": {
|
"Jameson": {
|
||||||
@@ -10920,7 +10920,7 @@
|
|||||||
"creamy",
|
"creamy",
|
||||||
"velvety",
|
"velvety",
|
||||||
"oily",
|
"oily",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"mellow",
|
"mellow",
|
||||||
"balanced",
|
"balanced",
|
||||||
"soft",
|
"soft",
|
||||||
@@ -11185,7 +11185,7 @@
|
|||||||
"waxy, candle-wax polish tone in older bottlings"
|
"waxy, candle-wax polish tone in older bottlings"
|
||||||
],
|
],
|
||||||
"texture": [
|
"texture": [
|
||||||
"creamy, silky and rounded mouthfeel",
|
"creamy, silky and rounded-sm mouthfeel",
|
||||||
"viscous and coating (especially single pot still proofs)",
|
"viscous and coating (especially single pot still proofs)",
|
||||||
"slightly oily and waxy",
|
"slightly oily and waxy",
|
||||||
"buttery and smooth (vanilla custard texture)",
|
"buttery and smooth (vanilla custard texture)",
|
||||||
@@ -11489,7 +11489,7 @@
|
|||||||
],
|
],
|
||||||
"texture": [
|
"texture": [
|
||||||
"Creamy",
|
"Creamy",
|
||||||
"Silky and rounded",
|
"Silky and rounded-sm",
|
||||||
"Medium viscosity",
|
"Medium viscosity",
|
||||||
"Oiliness that coats the palate",
|
"Oiliness that coats the palate",
|
||||||
"Soft and approachable",
|
"Soft and approachable",
|
||||||
@@ -11868,7 +11868,7 @@
|
|||||||
"well-balanced",
|
"well-balanced",
|
||||||
"lightly oily yet clean",
|
"lightly oily yet clean",
|
||||||
"medium-bodied",
|
"medium-bodied",
|
||||||
"rounded and cohesive",
|
"rounded-sm and cohesive",
|
||||||
"soft-spiced",
|
"soft-spiced",
|
||||||
"refreshing acidity",
|
"refreshing acidity",
|
||||||
"velvety oak impression",
|
"velvety oak impression",
|
||||||
@@ -12021,7 +12021,7 @@
|
|||||||
"elegant and restrained",
|
"elegant and restrained",
|
||||||
"refined and precise",
|
"refined and precise",
|
||||||
"high-definition clarity",
|
"high-definition clarity",
|
||||||
"smooth, rounded edges",
|
"smooth, rounded-sm edges",
|
||||||
"spry acidity (citrus lift)",
|
"spry acidity (citrus lift)",
|
||||||
"tight-grained oak feel",
|
"tight-grained oak feel",
|
||||||
"lifted and airy",
|
"lifted and airy",
|
||||||
@@ -12044,7 +12044,7 @@
|
|||||||
"hint of coconut and banana from Mizunara/inactive oak"
|
"hint of coconut and banana from Mizunara/inactive oak"
|
||||||
],
|
],
|
||||||
"taste": [
|
"taste": [
|
||||||
"soft, rounded mouthfeel",
|
"soft, rounded-sm mouthfeel",
|
||||||
"orchard fruit sweetness (pear, apple)",
|
"orchard fruit sweetness (pear, apple)",
|
||||||
"peach and apricot preserve",
|
"peach and apricot preserve",
|
||||||
"light floral notes (lilac, jasmine)",
|
"light floral notes (lilac, jasmine)",
|
||||||
@@ -12072,7 +12072,7 @@
|
|||||||
"texture": [
|
"texture": [
|
||||||
"silky and smooth",
|
"silky and smooth",
|
||||||
"light to medium body",
|
"light to medium body",
|
||||||
"crisp yet rounded",
|
"crisp yet rounded-sm",
|
||||||
"polished and clean",
|
"polished and clean",
|
||||||
"well-integrated alcohol",
|
"well-integrated alcohol",
|
||||||
"slightly oily with a fresh core",
|
"slightly oily with a fresh core",
|
||||||
@@ -12226,7 +12226,7 @@
|
|||||||
"Subtle smoke/char - faint ex-bourbon and Mizunara influence"
|
"Subtle smoke/char - faint ex-bourbon and Mizunara influence"
|
||||||
],
|
],
|
||||||
"taste": [
|
"taste": [
|
||||||
"Velvety malt - soft, rounded mid-palate",
|
"Velvety malt - soft, rounded-sm mid-palate",
|
||||||
"Pear and Nashi fruit - clean orchard sweetness",
|
"Pear and Nashi fruit - clean orchard sweetness",
|
||||||
"White peach and apricot - gentle stone fruit",
|
"White peach and apricot - gentle stone fruit",
|
||||||
"Citrus zest - lemon and yuzu acidity for balance",
|
"Citrus zest - lemon and yuzu acidity for balance",
|
||||||
@@ -12615,7 +12615,7 @@
|
|||||||
"creamy",
|
"creamy",
|
||||||
"silky",
|
"silky",
|
||||||
"smooth",
|
"smooth",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"balanced",
|
"balanced",
|
||||||
"firm",
|
"firm",
|
||||||
"robust",
|
"robust",
|
||||||
@@ -12764,7 +12764,7 @@
|
|||||||
"silky",
|
"silky",
|
||||||
"full-bodied",
|
"full-bodied",
|
||||||
"rich",
|
"rich",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"chewy",
|
"chewy",
|
||||||
"polished",
|
"polished",
|
||||||
"luscious",
|
"luscious",
|
||||||
@@ -12848,7 +12848,7 @@
|
|||||||
"rich",
|
"rich",
|
||||||
"viscous",
|
"viscous",
|
||||||
"structured",
|
"structured",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"bold",
|
"bold",
|
||||||
"smooth",
|
"smooth",
|
||||||
"dense",
|
"dense",
|
||||||
@@ -12985,7 +12985,7 @@
|
|||||||
"chewy",
|
"chewy",
|
||||||
"well-structured",
|
"well-structured",
|
||||||
"balanced",
|
"balanced",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"warming",
|
"warming",
|
||||||
"spicy prickle",
|
"spicy prickle",
|
||||||
"smooth",
|
"smooth",
|
||||||
@@ -13067,7 +13067,7 @@
|
|||||||
"hot and vibrant",
|
"hot and vibrant",
|
||||||
"slightly prickly",
|
"slightly prickly",
|
||||||
"buttery",
|
"buttery",
|
||||||
"rounded and integrated",
|
"rounded-sm and integrated",
|
||||||
"unctuous",
|
"unctuous",
|
||||||
"mouth-filling",
|
"mouth-filling",
|
||||||
"thick pour"
|
"thick pour"
|
||||||
@@ -13176,7 +13176,7 @@
|
|||||||
"dense",
|
"dense",
|
||||||
"full-bodied",
|
"full-bodied",
|
||||||
"rich",
|
"rich",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"well-integrated",
|
"well-integrated",
|
||||||
"balanced",
|
"balanced",
|
||||||
"layered",
|
"layered",
|
||||||
@@ -13273,7 +13273,7 @@
|
|||||||
"creamy",
|
"creamy",
|
||||||
"oily",
|
"oily",
|
||||||
"medium-bodied",
|
"medium-bodied",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"slightly spicy",
|
"slightly spicy",
|
||||||
"warm",
|
"warm",
|
||||||
"velvety",
|
"velvety",
|
||||||
@@ -13338,7 +13338,7 @@
|
|||||||
"luscious",
|
"luscious",
|
||||||
"chewy",
|
"chewy",
|
||||||
"rich",
|
"rich",
|
||||||
"rounded"
|
"rounded-sm"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Elijah Craig": {
|
"Elijah Craig": {
|
||||||
@@ -13719,7 +13719,7 @@
|
|||||||
"chewy",
|
"chewy",
|
||||||
"syrupy",
|
"syrupy",
|
||||||
"rich",
|
"rich",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"coating",
|
"coating",
|
||||||
"smooth",
|
"smooth",
|
||||||
"balanced",
|
"balanced",
|
||||||
@@ -13821,7 +13821,7 @@
|
|||||||
"velvety",
|
"velvety",
|
||||||
"silky",
|
"silky",
|
||||||
"chewy",
|
"chewy",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"well-integrated",
|
"well-integrated",
|
||||||
"balanced",
|
"balanced",
|
||||||
"smooth",
|
"smooth",
|
||||||
@@ -13966,7 +13966,7 @@
|
|||||||
"smooth",
|
"smooth",
|
||||||
"creamy",
|
"creamy",
|
||||||
"oily",
|
"oily",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"balanced",
|
"balanced",
|
||||||
"mellow",
|
"mellow",
|
||||||
"slightly viscous",
|
"slightly viscous",
|
||||||
@@ -14118,7 +14118,7 @@
|
|||||||
"creamy",
|
"creamy",
|
||||||
"velvety",
|
"velvety",
|
||||||
"oily",
|
"oily",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"syrupy",
|
"syrupy",
|
||||||
"chewy",
|
"chewy",
|
||||||
"medium-bodied",
|
"medium-bodied",
|
||||||
@@ -14224,7 +14224,7 @@
|
|||||||
"spirited",
|
"spirited",
|
||||||
"well-integrated",
|
"well-integrated",
|
||||||
"structured",
|
"structured",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"luscious",
|
"luscious",
|
||||||
"layered",
|
"layered",
|
||||||
"viscous",
|
"viscous",
|
||||||
@@ -14365,7 +14365,7 @@
|
|||||||
"Creamy mouthfeel",
|
"Creamy mouthfeel",
|
||||||
"Dense and rich",
|
"Dense and rich",
|
||||||
"Syrupy sweetness balanced by oak",
|
"Syrupy sweetness balanced by oak",
|
||||||
"Soft and rounded",
|
"Soft and rounded-sm",
|
||||||
"Velvety tannins",
|
"Velvety tannins",
|
||||||
"Warming spice prickle",
|
"Warming spice prickle",
|
||||||
"Chewy and substantial",
|
"Chewy and substantial",
|
||||||
@@ -14506,7 +14506,7 @@
|
|||||||
"full-bodied",
|
"full-bodied",
|
||||||
"rich",
|
"rich",
|
||||||
"coating",
|
"coating",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"dense",
|
"dense",
|
||||||
"bold",
|
"bold",
|
||||||
"thick",
|
"thick",
|
||||||
@@ -14592,7 +14592,7 @@
|
|||||||
"chewy",
|
"chewy",
|
||||||
"rich",
|
"rich",
|
||||||
"well-integrated",
|
"well-integrated",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"luscious",
|
"luscious",
|
||||||
"dense",
|
"dense",
|
||||||
"polished",
|
"polished",
|
||||||
@@ -14818,7 +14818,7 @@
|
|||||||
"Luscious and succulent",
|
"Luscious and succulent",
|
||||||
"Polished and well-integrated",
|
"Polished and well-integrated",
|
||||||
"Silky with a subtle grip",
|
"Silky with a subtle grip",
|
||||||
"Buttery and rounded",
|
"Buttery and rounded-sm",
|
||||||
"Warm and enveloping",
|
"Warm and enveloping",
|
||||||
"Concentrated and intense"
|
"Concentrated and intense"
|
||||||
]
|
]
|
||||||
@@ -14870,7 +14870,7 @@
|
|||||||
"Oiliness from pot still character",
|
"Oiliness from pot still character",
|
||||||
"Creamy mouthfeel with soft oak grip",
|
"Creamy mouthfeel with soft oak grip",
|
||||||
"Slightly viscous with tropical weight",
|
"Slightly viscous with tropical weight",
|
||||||
"Polished and rounded tannins",
|
"Polished and rounded-sm tannins",
|
||||||
"Bright but not sharp, approachable",
|
"Bright but not sharp, approachable",
|
||||||
"Warming spice prickle",
|
"Warming spice prickle",
|
||||||
"Creamy vanilla custard texture",
|
"Creamy vanilla custard texture",
|
||||||
@@ -14931,7 +14931,7 @@
|
|||||||
"polished oak texture",
|
"polished oak texture",
|
||||||
"brine-tinged grip",
|
"brine-tinged grip",
|
||||||
"cocoa-dusted smoothness",
|
"cocoa-dusted smoothness",
|
||||||
"rounded yet angular spice",
|
"rounded-sm yet angular spice",
|
||||||
"medium-to-full bodied",
|
"medium-to-full bodied",
|
||||||
"lively pepper-prickly sensation"
|
"lively pepper-prickly sensation"
|
||||||
]
|
]
|
||||||
@@ -15027,7 +15027,7 @@
|
|||||||
"syrupy and viscous",
|
"syrupy and viscous",
|
||||||
"silky and velvety",
|
"silky and velvety",
|
||||||
"coating and mouth-coating",
|
"coating and mouth-coating",
|
||||||
"rounded and plush",
|
"rounded-sm and plush",
|
||||||
"spicy-prickly (white pepper)",
|
"spicy-prickly (white pepper)",
|
||||||
"warming yet refreshing",
|
"warming yet refreshing",
|
||||||
"balanced heat from virgin oak",
|
"balanced heat from virgin oak",
|
||||||
@@ -15152,7 +15152,7 @@
|
|||||||
"oily (coat the palate)",
|
"oily (coat the palate)",
|
||||||
"slightly syrupy",
|
"slightly syrupy",
|
||||||
"well-integrated alcohol",
|
"well-integrated alcohol",
|
||||||
"rounded oak texture",
|
"rounded-sm oak texture",
|
||||||
"polished tannin",
|
"polished tannin",
|
||||||
"creamy (from vanilla/caramel)",
|
"creamy (from vanilla/caramel)",
|
||||||
"bright acidity (wine-cask lift)"
|
"bright acidity (wine-cask lift)"
|
||||||
@@ -15706,7 +15706,7 @@
|
|||||||
],
|
],
|
||||||
"texture": [
|
"texture": [
|
||||||
"Silky and creamy mouthfeel",
|
"Silky and creamy mouthfeel",
|
||||||
"Medium body with a rounded profile",
|
"Medium body with a rounded-sm profile",
|
||||||
"Polished and gently coating",
|
"Polished and gently coating",
|
||||||
"Juicy fruit sensation",
|
"Juicy fruit sensation",
|
||||||
"Slightly waxy on the mid-palate",
|
"Slightly waxy on the mid-palate",
|
||||||
@@ -15955,7 +15955,7 @@
|
|||||||
"balanced dryness without harsh astringency"
|
"balanced dryness without harsh astringency"
|
||||||
],
|
],
|
||||||
"texture": [
|
"texture": [
|
||||||
"silky and rounded mouthfeel",
|
"silky and rounded-sm mouthfeel",
|
||||||
"medium body, neither oily nor watery",
|
"medium body, neither oily nor watery",
|
||||||
"slightly waxy on the palate",
|
"slightly waxy on the palate",
|
||||||
"creamy texture reminiscent of crème anglaise",
|
"creamy texture reminiscent of crème anglaise",
|
||||||
@@ -16073,7 +16073,7 @@
|
|||||||
"well-integrated alcohol",
|
"well-integrated alcohol",
|
||||||
"slightly drying",
|
"slightly drying",
|
||||||
"silky",
|
"silky",
|
||||||
"rounded mouthfeel",
|
"rounded-sm mouthfeel",
|
||||||
"soft and approachable",
|
"soft and approachable",
|
||||||
"polished oak"
|
"polished oak"
|
||||||
]
|
]
|
||||||
@@ -16133,7 +16133,7 @@
|
|||||||
"texture": [
|
"texture": [
|
||||||
"silky",
|
"silky",
|
||||||
"oily",
|
"oily",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"well-balanced",
|
"well-balanced",
|
||||||
"moderately creamy",
|
"moderately creamy",
|
||||||
"polished",
|
"polished",
|
||||||
@@ -16253,7 +16253,7 @@
|
|||||||
"smooth and approachable",
|
"smooth and approachable",
|
||||||
"concentrated and dense",
|
"concentrated and dense",
|
||||||
"chalky-dry towards the end",
|
"chalky-dry towards the end",
|
||||||
"rounded but structured",
|
"rounded-sm but structured",
|
||||||
"supple with a citrusy cut",
|
"supple with a citrusy cut",
|
||||||
"polished oak texture"
|
"polished oak texture"
|
||||||
]
|
]
|
||||||
@@ -16310,11 +16310,11 @@
|
|||||||
"full-bodied for low ABV",
|
"full-bodied for low ABV",
|
||||||
"effervescent prickle",
|
"effervescent prickle",
|
||||||
"sprightly zing",
|
"sprightly zing",
|
||||||
"smooth and rounded",
|
"smooth and rounded-sm",
|
||||||
"viscous syrup",
|
"viscous syrup",
|
||||||
"well-integrated heat",
|
"well-integrated heat",
|
||||||
"slightly waxy",
|
"slightly waxy",
|
||||||
"rounded mouthfeel"
|
"rounded-sm mouthfeel"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Berry Bros & Rudd": {
|
"Berry Bros & Rudd": {
|
||||||
@@ -16374,7 +16374,7 @@
|
|||||||
"silky/velvety mid-palate",
|
"silky/velvety mid-palate",
|
||||||
"medium-bodied and balanced",
|
"medium-bodied and balanced",
|
||||||
"slightly oily with grip",
|
"slightly oily with grip",
|
||||||
"crisp yet rounded",
|
"crisp yet rounded-sm",
|
||||||
"chalky/mineral edge",
|
"chalky/mineral edge",
|
||||||
"polished oak feel",
|
"polished oak feel",
|
||||||
"creamy without being heavy",
|
"creamy without being heavy",
|
||||||
@@ -16678,7 +16678,7 @@
|
|||||||
"medium-bodied",
|
"medium-bodied",
|
||||||
"slightly oily",
|
"slightly oily",
|
||||||
"polished",
|
"polished",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"well-integrated alcohol",
|
"well-integrated alcohol",
|
||||||
"soft",
|
"soft",
|
||||||
"crisp",
|
"crisp",
|
||||||
@@ -17228,7 +17228,7 @@
|
|||||||
"dense",
|
"dense",
|
||||||
"oily-tear legs",
|
"oily-tear legs",
|
||||||
"weighty",
|
"weighty",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"polished",
|
"polished",
|
||||||
"plush",
|
"plush",
|
||||||
"satiny",
|
"satiny",
|
||||||
@@ -17396,7 +17396,7 @@
|
|||||||
"clean smoke / ember whisper"
|
"clean smoke / ember whisper"
|
||||||
],
|
],
|
||||||
"texture": [
|
"texture": [
|
||||||
"smooth and rounded",
|
"smooth and rounded-sm",
|
||||||
"silky / velvety",
|
"silky / velvety",
|
||||||
"creamy (reminiscent of crème anglaise)",
|
"creamy (reminiscent of crème anglaise)",
|
||||||
"medium-bodied and well-balanced",
|
"medium-bodied and well-balanced",
|
||||||
@@ -17444,7 +17444,7 @@
|
|||||||
"gentle peat (whisper)"
|
"gentle peat (whisper)"
|
||||||
],
|
],
|
||||||
"finish": [
|
"finish": [
|
||||||
"smooth and rounded",
|
"smooth and rounded-sm",
|
||||||
"lingering honey",
|
"lingering honey",
|
||||||
"soft oak",
|
"soft oak",
|
||||||
"dried fruit sultanas",
|
"dried fruit sultanas",
|
||||||
@@ -17461,7 +17461,7 @@
|
|||||||
"creamy",
|
"creamy",
|
||||||
"well-balanced",
|
"well-balanced",
|
||||||
"soft",
|
"soft",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"slightly oily",
|
"slightly oily",
|
||||||
"polished",
|
"polished",
|
||||||
"approachable",
|
"approachable",
|
||||||
@@ -17532,7 +17532,7 @@
|
|||||||
],
|
],
|
||||||
"texture": [
|
"texture": [
|
||||||
"smooth and polished",
|
"smooth and polished",
|
||||||
"creamy and rounded",
|
"creamy and rounded-sm",
|
||||||
"medium-bodied and balanced",
|
"medium-bodied and balanced",
|
||||||
"silky mouthfeel",
|
"silky mouthfeel",
|
||||||
"well-integrated alcohol (no harshness)",
|
"well-integrated alcohol (no harshness)",
|
||||||
@@ -17640,7 +17640,7 @@
|
|||||||
"butterscotch"
|
"butterscotch"
|
||||||
],
|
],
|
||||||
"finish": [
|
"finish": [
|
||||||
"smooth and rounded",
|
"smooth and rounded-sm",
|
||||||
"medium length",
|
"medium length",
|
||||||
"lingering honey and vanilla",
|
"lingering honey and vanilla",
|
||||||
"sweet oak and gentle spice",
|
"sweet oak and gentle spice",
|
||||||
@@ -17769,7 +17769,7 @@
|
|||||||
"balanced sweetness and spice"
|
"balanced sweetness and spice"
|
||||||
],
|
],
|
||||||
"finish": [
|
"finish": [
|
||||||
"smooth and rounded",
|
"smooth and rounded-sm",
|
||||||
"lingering smokiness",
|
"lingering smokiness",
|
||||||
"creamy vanilla",
|
"creamy vanilla",
|
||||||
"oak-driven warmth",
|
"oak-driven warmth",
|
||||||
@@ -17790,7 +17790,7 @@
|
|||||||
"velvety",
|
"velvety",
|
||||||
"silky",
|
"silky",
|
||||||
"medium-bodied",
|
"medium-bodied",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"oily",
|
"oily",
|
||||||
"smooth",
|
"smooth",
|
||||||
"soft",
|
"soft",
|
||||||
@@ -17866,7 +17866,7 @@
|
|||||||
"approachable",
|
"approachable",
|
||||||
"smooth",
|
"smooth",
|
||||||
"sprightly",
|
"sprightly",
|
||||||
"rounded mouthfeel",
|
"rounded-sm mouthfeel",
|
||||||
"gentle"
|
"gentle"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -17930,7 +17930,7 @@
|
|||||||
"texture": [
|
"texture": [
|
||||||
"creamy",
|
"creamy",
|
||||||
"oily",
|
"oily",
|
||||||
"rounded",
|
"rounded-sm",
|
||||||
"full-bodied",
|
"full-bodied",
|
||||||
"silky",
|
"silky",
|
||||||
"weighted",
|
"weighted",
|
||||||
@@ -17989,7 +17989,7 @@
|
|||||||
"creamy and velvety",
|
"creamy and velvety",
|
||||||
"oily and coating",
|
"oily and coating",
|
||||||
"waxy and beeswax-like",
|
"waxy and beeswax-like",
|
||||||
"rounded and mellow",
|
"rounded-sm and mellow",
|
||||||
"medium-bodied and balanced",
|
"medium-bodied and balanced",
|
||||||
"silky and smooth",
|
"silky and smooth",
|
||||||
"slightly drying oak",
|
"slightly drying oak",
|
||||||
|
|||||||
@@ -1,12 +1,48 @@
|
|||||||
|
import { withSentryConfig } from "@sentry/nextjs";
|
||||||
|
|
||||||
/** @type {import('next').Config} */
|
/** @type {import('next').Config} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
productionBrowserSourceMaps: false,
|
// Enable source maps for Sentry stack traces in production
|
||||||
|
productionBrowserSourceMaps: !!process.env.GLITCHTIP_DSN,
|
||||||
|
// React Compiler for automatic memoization (React 19+)
|
||||||
|
reactCompiler: true,
|
||||||
experimental: {
|
experimental: {
|
||||||
|
// Note: cacheComponents (PPR) disabled - requires Suspense boundaries for all auth contexts
|
||||||
|
// Can be enabled later after refactoring to RSC-first architecture
|
||||||
serverActions: {
|
serverActions: {
|
||||||
bodySizeLimit: '10mb',
|
bodySizeLimit: '10mb',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
// Wrap with Sentry only if DSN is configured
|
||||||
|
const sentryEnabled = !!process.env.GLITCHTIP_DSN || !!process.env.NEXT_PUBLIC_GLITCHTIP_DSN;
|
||||||
|
|
||||||
|
const sentryWebpackPluginOptions = {
|
||||||
|
// Suppresses source map uploading logs during build
|
||||||
|
silent: true,
|
||||||
|
|
||||||
|
// Organization and project slugs (optional - for source map upload)
|
||||||
|
org: process.env.GLITCHTIP_ORG,
|
||||||
|
project: process.env.GLITCHTIP_PROJECT,
|
||||||
|
|
||||||
|
// GlitchTip server URL
|
||||||
|
sentryUrl: process.env.GLITCHTIP_URL,
|
||||||
|
|
||||||
|
// Auth token for source map upload
|
||||||
|
authToken: process.env.GLITCHTIP_AUTH_TOKEN,
|
||||||
|
|
||||||
|
// Hides source maps from generated client bundles
|
||||||
|
hideSourceMaps: true,
|
||||||
|
|
||||||
|
// Automatically tree-shake Sentry logger statements
|
||||||
|
disableLogger: true,
|
||||||
|
|
||||||
|
// Prevent bundling of native binaries
|
||||||
|
widenClientFileUpload: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default sentryEnabled
|
||||||
|
? withSentryConfig(nextConfig, sentryWebpackPluginOptions)
|
||||||
|
: nextConfig;
|
||||||
|
|||||||
@@ -15,9 +15,11 @@
|
|||||||
"@ai-sdk/google": "^2.0.51",
|
"@ai-sdk/google": "^2.0.51",
|
||||||
"@google/generative-ai": "^0.24.1",
|
"@google/generative-ai": "^0.24.1",
|
||||||
"@mistralai/mistralai": "^1.11.0",
|
"@mistralai/mistralai": "^1.11.0",
|
||||||
|
"@sentry/nextjs": "^10.34.0",
|
||||||
"@supabase/ssr": "^0.5.2",
|
"@supabase/ssr": "^0.5.2",
|
||||||
"@supabase/supabase-js": "^2.47.10",
|
"@supabase/supabase-js": "^2.47.10",
|
||||||
"@tanstack/react-query": "^5.62.7",
|
"@tanstack/react-query": "^5.62.7",
|
||||||
|
"@xenova/transformers": "^2.17.2",
|
||||||
"ai": "^5.0.116",
|
"ai": "^5.0.116",
|
||||||
"browser-image-compression": "^2.0.2",
|
"browser-image-compression": "^2.0.2",
|
||||||
"canvas-confetti": "^1.9.3",
|
"canvas-confetti": "^1.9.3",
|
||||||
@@ -42,6 +44,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.57.0",
|
"@playwright/test": "^1.57.0",
|
||||||
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.1",
|
"@testing-library/react": "^16.3.1",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
@@ -49,13 +52,13 @@
|
|||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"@vitejs/plugin-react": "^5.1.2",
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
"autoprefixer": "^10.0.1",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "16.1.0",
|
"eslint-config-next": "16.1.0",
|
||||||
"eslint-plugin-security": "^2.1.1",
|
"eslint-plugin-security": "^2.1.1",
|
||||||
"jsdom": "^27.3.0",
|
"jsdom": "^27.3.0",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwindcss": "^3.3.0",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"vitest": "^4.0.16"
|
"vitest": "^4.0.16"
|
||||||
},
|
},
|
||||||
|
|||||||
2797
pnpm-lock.yaml
generated
2797
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,5 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
'@tailwindcss/postcss': {},
|
||||||
autoprefixer: {},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
123
public/bg-processor.worker.js
Normal file
123
public/bg-processor.worker.js
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
// Background Removal Worker using briaai/RMBG-1.4
|
||||||
|
// Using @huggingface/transformers v3
|
||||||
|
|
||||||
|
import { AutoModel, AutoProcessor, RawImage, env } from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.5.1';
|
||||||
|
|
||||||
|
console.log('[BG-Processor Worker] Script loaded from /public');
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
env.allowLocalModels = false;
|
||||||
|
env.useBrowserCache = true;
|
||||||
|
// Force WASM backend (more compatible)
|
||||||
|
env.backends.onnx.wasm.proxy = false;
|
||||||
|
|
||||||
|
let model = null;
|
||||||
|
let processor = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the RMBG-1.4 model (WASM only for compatibility)
|
||||||
|
*/
|
||||||
|
const loadModel = async () => {
|
||||||
|
if (!model) {
|
||||||
|
console.log('[BG-Processor Worker] Loading briaai/RMBG-1.4 model (WASM)...');
|
||||||
|
model = await AutoModel.from_pretrained('briaai/RMBG-1.4', {
|
||||||
|
device: 'wasm',
|
||||||
|
dtype: 'fp32',
|
||||||
|
});
|
||||||
|
processor = await AutoProcessor.from_pretrained('briaai/RMBG-1.4');
|
||||||
|
console.log('[BG-Processor Worker] Model loaded successfully.');
|
||||||
|
}
|
||||||
|
return { model, processor };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the alpha mask to the original image
|
||||||
|
*/
|
||||||
|
const applyMask = async (originalBlob, maskData, width, height) => {
|
||||||
|
const bitmap = await createImageBitmap(originalBlob);
|
||||||
|
|
||||||
|
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) throw new Error("No Canvas context");
|
||||||
|
|
||||||
|
// Draw original image
|
||||||
|
ctx.drawImage(bitmap, 0, 0);
|
||||||
|
|
||||||
|
// Get image data
|
||||||
|
const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
|
||||||
|
const data = imageData.data;
|
||||||
|
|
||||||
|
// Create mask canvas at model output size
|
||||||
|
const maskCanvas = new OffscreenCanvas(width, height);
|
||||||
|
const maskCtx = maskCanvas.getContext('2d');
|
||||||
|
const maskImageData = maskCtx.createImageData(width, height);
|
||||||
|
|
||||||
|
// Convert model output to grayscale image
|
||||||
|
for (let i = 0; i < maskData.length; i++) {
|
||||||
|
const val = Math.round(Math.max(0, Math.min(1, maskData[i])) * 255);
|
||||||
|
maskImageData.data[i * 4] = val;
|
||||||
|
maskImageData.data[i * 4 + 1] = val;
|
||||||
|
maskImageData.data[i * 4 + 2] = val;
|
||||||
|
maskImageData.data[i * 4 + 3] = 255;
|
||||||
|
}
|
||||||
|
maskCtx.putImageData(maskImageData, 0, 0);
|
||||||
|
|
||||||
|
// Scale mask to original size
|
||||||
|
const scaledMaskCanvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
||||||
|
const scaledMaskCtx = scaledMaskCanvas.getContext('2d');
|
||||||
|
scaledMaskCtx.drawImage(maskCanvas, 0, 0, bitmap.width, bitmap.height);
|
||||||
|
const scaledMaskData = scaledMaskCtx.getImageData(0, 0, bitmap.width, bitmap.height);
|
||||||
|
|
||||||
|
// Apply mask as alpha
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
data[i + 3] = scaledMaskData.data[i]; // Use R channel as alpha
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
return await canvas.convertToBlob({ type: 'image/png' });
|
||||||
|
};
|
||||||
|
|
||||||
|
self.onmessage = async (e) => {
|
||||||
|
const { type, id, imageBlob } = e.data;
|
||||||
|
|
||||||
|
if (type === 'ping') {
|
||||||
|
self.postMessage({ type: 'pong' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!imageBlob) return;
|
||||||
|
|
||||||
|
console.log(`[BG-Processor Worker] Received request for ${id}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { model, processor } = await loadModel();
|
||||||
|
|
||||||
|
// Convert blob to RawImage
|
||||||
|
const url = URL.createObjectURL(imageBlob);
|
||||||
|
const image = await RawImage.fromURL(url);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
console.log('[BG-Processor Worker] Running inference...');
|
||||||
|
|
||||||
|
// Process image
|
||||||
|
const { pixel_values } = await processor(image);
|
||||||
|
|
||||||
|
// Run model
|
||||||
|
const { output } = await model({ input: pixel_values });
|
||||||
|
|
||||||
|
// Get mask data - output is a Tensor
|
||||||
|
const maskData = output.data;
|
||||||
|
const [batch, channels, height, width] = output.dims;
|
||||||
|
|
||||||
|
console.log(`[BG-Processor Worker] Mask dims: ${width}x${height}`);
|
||||||
|
console.log('[BG-Processor Worker] Applying mask...');
|
||||||
|
|
||||||
|
const processedBlob = await applyMask(imageBlob, maskData, width, height);
|
||||||
|
|
||||||
|
self.postMessage({ id, status: 'success', blob: processedBlob });
|
||||||
|
console.log(`[BG-Processor Worker] Successfully processed ${id}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[BG-Processor Worker] Processing Error (${id}):`, err);
|
||||||
|
self.postMessage({ id, status: 'error', error: err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
23
public/sw.js
23
public/sw.js
@@ -1,4 +1,4 @@
|
|||||||
const CACHE_NAME = 'whisky-vault-v20-offline';
|
const CACHE_NAME = 'whisky-vault-v21-offline';
|
||||||
|
|
||||||
// CONFIG: Assets - Only essential files, no heavy OCR (~2MB instead of ~50MB)
|
// CONFIG: Assets - Only essential files, no heavy OCR (~2MB instead of ~50MB)
|
||||||
const STATIC_ASSETS = [
|
const STATIC_ASSETS = [
|
||||||
@@ -189,22 +189,33 @@ self.addEventListener('fetch', (event) => {
|
|||||||
if (isNavigation || isAsset) {
|
if (isNavigation || isAsset) {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
caches.match(event.request).then(async (cachedResponse) => {
|
caches.match(event.request).then(async (cachedResponse) => {
|
||||||
const fetchPromise = fetchWithTimeout(event.request, 10000)
|
// Try network first
|
||||||
.then(async (networkResponse) => {
|
try {
|
||||||
|
const networkResponse = await fetchWithTimeout(event.request, 10000);
|
||||||
if (networkResponse && networkResponse.status === 200) {
|
if (networkResponse && networkResponse.status === 200) {
|
||||||
const cache = await caches.open(CACHE_NAME);
|
const cache = await caches.open(CACHE_NAME);
|
||||||
cache.put(event.request, networkResponse.clone());
|
cache.put(event.request, networkResponse.clone());
|
||||||
}
|
}
|
||||||
return networkResponse;
|
return networkResponse;
|
||||||
}).catch(() => { });
|
} catch (networkError) {
|
||||||
|
// Network failed, fall back to cache
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For navigation, try to serve the app shell
|
||||||
if (isNavigation) {
|
if (isNavigation) {
|
||||||
if (cachedResponse) return cachedResponse;
|
|
||||||
const shell = await caches.match('/');
|
const shell = await caches.match('/');
|
||||||
if (shell) return shell;
|
if (shell) return shell;
|
||||||
}
|
}
|
||||||
|
|
||||||
return cachedResponse || fetchPromise || fetch(event.request);
|
// Last resort: return a proper error response
|
||||||
|
return new Response('Offline - Resource not available', {
|
||||||
|
status: 503,
|
||||||
|
statusText: 'Service Unavailable',
|
||||||
|
headers: { 'Content-Type': 'text/plain' }
|
||||||
|
});
|
||||||
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
99
security-report.txt
Normal file
99
security-report.txt
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
|
||||||
|
|
||||||
|
┌──────────────────┐
|
||||||
|
│ 15 Code Findings │
|
||||||
|
└──────────────────┘
|
||||||
|
|
||||||
|
public/sw.js
|
||||||
|
❱ javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring
|
||||||
|
Detected string concatenation with a non-literal variable in a util.format / console.log function.
|
||||||
|
If an attacker injects a format specifier in the string, it will forge the log message. Try to use
|
||||||
|
constant values for the format string.
|
||||||
|
Details: https://sg.run/7Y5R
|
||||||
|
|
||||||
|
75┆ console.error(`⚠️ PWA: Pre-cache failed for ${url}:`, error);
|
||||||
|
⋮┆----------------------------------------
|
||||||
|
174┆ console.error(`[SW] Failed to fetch ${url.pathname}:`, error);
|
||||||
|
|
||||||
|
scripts/scrape-distillery-tags.ts
|
||||||
|
❱ javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring
|
||||||
|
Detected string concatenation with a non-literal variable in a util.format / console.log function.
|
||||||
|
If an attacker injects a format specifier in the string, it will forge the log message. Try to use
|
||||||
|
constant values for the format string.
|
||||||
|
Details: https://sg.run/7Y5R
|
||||||
|
|
||||||
|
107┆ console.error(`❌ API Error for ${name}: ${response.status}`, data.error || data);
|
||||||
|
⋮┆----------------------------------------
|
||||||
|
116┆ console.error(`⚠️ OpenRouter Error for ${name}:`, data.error.message);
|
||||||
|
⋮┆----------------------------------------
|
||||||
|
119┆ console.error(`⚠️ No content returned for ${name}. Full response:`, JSON.stringify(data,
|
||||||
|
null, 2));
|
||||||
|
⋮┆----------------------------------------
|
||||||
|
125┆ console.error(`❌ Fetch Exception for ${name}:`, error);
|
||||||
|
|
||||||
|
src/context/AuthContext.tsx
|
||||||
|
❱ javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring
|
||||||
|
Detected string concatenation with a non-literal variable in a util.format / console.log function.
|
||||||
|
If an attacker injects a format specifier in the string, it will forge the log message. Try to use
|
||||||
|
constant values for the format string.
|
||||||
|
Details: https://sg.run/7Y5R
|
||||||
|
|
||||||
|
40┆ console.log(`[AuthContext] event: ${event}`, {
|
||||||
|
|
||||||
|
src/hooks/useScanner.ts
|
||||||
|
❱ javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring
|
||||||
|
Detected string concatenation with a non-literal variable in a util.format / console.log function.
|
||||||
|
If an attacker injects a format specifier in the string, it will forge the log message. Try to use
|
||||||
|
constant values for the format string.
|
||||||
|
Details: https://sg.run/7Y5R
|
||||||
|
|
||||||
|
157┆ console.log(`[useScanner] ${providerUsed} complete:`, cloudResult);
|
||||||
|
⋮┆----------------------------------------
|
||||||
|
186┆ console.warn(`[useScanner] ${providerUsed} failed:`, cloudResponse.error);
|
||||||
|
|
||||||
|
src/i18n/I18nContext.tsx
|
||||||
|
❯❱ javascript.lang.security.audit.prototype-pollution.prototype-pollution-loop.prototype-pollution-loop
|
||||||
|
Possibility of prototype polluting function detected. By adding or modifying attributes of an object
|
||||||
|
prototype, it is possible to create attributes that exist on every object, or replace critical
|
||||||
|
attributes with malicious ones. This can be problematic if the software depends on existence or non-
|
||||||
|
existence of certain attributes, or uses pre-defined attributes of object prototype (such as
|
||||||
|
hasOwnProperty, toString or valueOf). Possible mitigations might be: freezing the object prototype,
|
||||||
|
using an object without prototypes (via Object.create(null) ), blocking modifications of attributes
|
||||||
|
that resolve to object prototype, using Map instead of object.
|
||||||
|
Details: https://sg.run/w1DB
|
||||||
|
|
||||||
|
54┆ current = current[key];
|
||||||
|
|
||||||
|
src/lib/distillery-matcher.ts
|
||||||
|
❯❱ javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp
|
||||||
|
RegExp() called with a `distillery` function argument, this might allow an attacker to cause a
|
||||||
|
Regular Expression Denial-of-Service (ReDoS) within your application as RegExP blocks the main
|
||||||
|
thread. For this reason, it is recommended to use hardcoded regexes instead. If your regex is run on
|
||||||
|
user-controlled input, consider performing input validation or use a regex checking/sanitization
|
||||||
|
library such as https://www.npmjs.com/package/recheck to verify that the regex does not appear
|
||||||
|
vulnerable to ReDoS.
|
||||||
|
Details: https://sg.run/gr65
|
||||||
|
|
||||||
|
154┆ const regex = new RegExp(`^${escaped}\\s*[-–—:]?\\s*`, 'i');
|
||||||
|
⋮┆----------------------------------------
|
||||||
|
161┆ const anywhereRegex = new RegExp(`\\b${escaped}\\b\\s*[-–—:]?\\s*`, 'i');
|
||||||
|
|
||||||
|
src/services/bulk-scan.ts
|
||||||
|
❱ javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring
|
||||||
|
Detected string concatenation with a non-literal variable in a util.format / console.log function.
|
||||||
|
If an attacker injects a format specifier in the string, it will forge the log message. Try to use
|
||||||
|
constant values for the format string.
|
||||||
|
Details: https://sg.run/7Y5R
|
||||||
|
|
||||||
|
211┆ console.error(`Analysis failed for bottle ${bottleId}:`, error);
|
||||||
|
|
||||||
|
src/services/tags.ts
|
||||||
|
❱ javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring
|
||||||
|
Detected string concatenation with a non-literal variable in a util.format / console.log function.
|
||||||
|
If an attacker injects a format specifier in the string, it will forge the log message. Try to use
|
||||||
|
constant values for the format string.
|
||||||
|
Details: https://sg.run/7Y5R
|
||||||
|
|
||||||
|
33┆ console.error(`Error fetching tags for ${category}:`, error);
|
||||||
|
⋮┆----------------------------------------
|
||||||
|
39┆ console.error(`Exception in getTagsByCategory for ${category}:`, err);
|
||||||
36
sentry.client.config.ts
Normal file
36
sentry.client.config.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
|
const GLITCHTIP_DSN = process.env.NEXT_PUBLIC_GLITCHTIP_DSN;
|
||||||
|
|
||||||
|
// Only initialize Sentry if DSN is configured
|
||||||
|
if (GLITCHTIP_DSN) {
|
||||||
|
Sentry.init({
|
||||||
|
dsn: GLITCHTIP_DSN,
|
||||||
|
|
||||||
|
// Environment
|
||||||
|
environment: process.env.NODE_ENV,
|
||||||
|
|
||||||
|
// Sample rate for error events (1.0 = 100%)
|
||||||
|
sampleRate: 1.0,
|
||||||
|
|
||||||
|
// Performance monitoring sample rate (0.1 = 10%)
|
||||||
|
tracesSampleRate: 0.1,
|
||||||
|
|
||||||
|
// Use tunnel to bypass ad blockers
|
||||||
|
tunnel: "/api/glitchtip-tunnel",
|
||||||
|
|
||||||
|
// Disable debug in production
|
||||||
|
debug: process.env.NODE_ENV === "development",
|
||||||
|
|
||||||
|
// Ignore common non-actionable errors
|
||||||
|
ignoreErrors: [
|
||||||
|
"ResizeObserver loop limit exceeded",
|
||||||
|
"ResizeObserver loop completed with undelivered notifications",
|
||||||
|
"Non-Error promise rejection captured",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[Sentry] Client initialized with GlitchTip");
|
||||||
|
} else {
|
||||||
|
console.log("[Sentry] Client disabled - no DSN configured");
|
||||||
|
}
|
||||||
21
sentry.edge.config.ts
Normal file
21
sentry.edge.config.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
|
const GLITCHTIP_DSN = process.env.GLITCHTIP_DSN || process.env.NEXT_PUBLIC_GLITCHTIP_DSN;
|
||||||
|
|
||||||
|
// Only initialize Sentry if DSN is configured
|
||||||
|
if (GLITCHTIP_DSN) {
|
||||||
|
Sentry.init({
|
||||||
|
dsn: GLITCHTIP_DSN,
|
||||||
|
|
||||||
|
// Environment
|
||||||
|
environment: process.env.NODE_ENV,
|
||||||
|
|
||||||
|
// Sample rate for error events (1.0 = 100%)
|
||||||
|
sampleRate: 1.0,
|
||||||
|
|
||||||
|
// Performance monitoring sample rate (lower for edge)
|
||||||
|
tracesSampleRate: 0.05,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[Sentry] Edge initialized with GlitchTip");
|
||||||
|
}
|
||||||
26
sentry.server.config.ts
Normal file
26
sentry.server.config.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
|
const GLITCHTIP_DSN = process.env.GLITCHTIP_DSN || process.env.NEXT_PUBLIC_GLITCHTIP_DSN;
|
||||||
|
|
||||||
|
// Only initialize Sentry if DSN is configured
|
||||||
|
if (GLITCHTIP_DSN) {
|
||||||
|
Sentry.init({
|
||||||
|
dsn: GLITCHTIP_DSN,
|
||||||
|
|
||||||
|
// Environment
|
||||||
|
environment: process.env.NODE_ENV,
|
||||||
|
|
||||||
|
// Sample rate for error events (1.0 = 100%)
|
||||||
|
sampleRate: 1.0,
|
||||||
|
|
||||||
|
// Performance monitoring sample rate (0.1 = 10%)
|
||||||
|
tracesSampleRate: 0.1,
|
||||||
|
|
||||||
|
// Disable debug in production
|
||||||
|
debug: process.env.NODE_ENV === "development",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[Sentry] Server initialized with GlitchTip");
|
||||||
|
} else {
|
||||||
|
console.log("[Sentry] Server disabled - no DSN configured");
|
||||||
|
}
|
||||||
49
sql/create_app_banners.sql
Normal file
49
sql/create_app_banners.sql
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
-- App Banners Table for dynamic hero content on home page
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS app_banners (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
image_url TEXT NOT NULL, -- 16:9 Banner Image
|
||||||
|
link_target TEXT, -- e.g., '/sessions'
|
||||||
|
cta_text TEXT DEFAULT 'Open',
|
||||||
|
is_active BOOLEAN DEFAULT false,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Only one banner should be active at a time (optional constraint)
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_app_banners_active
|
||||||
|
ON app_banners (is_active)
|
||||||
|
WHERE is_active = true;
|
||||||
|
|
||||||
|
-- RLS Policies
|
||||||
|
ALTER TABLE app_banners ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Everyone can view active banners
|
||||||
|
CREATE POLICY "Anyone can view active banners"
|
||||||
|
ON app_banners FOR SELECT
|
||||||
|
USING (is_active = true);
|
||||||
|
|
||||||
|
-- Admins can manage all banners
|
||||||
|
CREATE POLICY "Admins can manage banners"
|
||||||
|
ON app_banners FOR ALL
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM admin_users
|
||||||
|
WHERE admin_users.user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Trigger for updated_at
|
||||||
|
CREATE OR REPLACE FUNCTION update_app_banners_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = now();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER trigger_app_banners_updated_at
|
||||||
|
BEFORE UPDATE ON app_banners
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_app_banners_updated_at();
|
||||||
78
sql/create_ocr_logs.sql
Normal file
78
sql/create_ocr_logs.sql
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
-- OCR Logs Table for storing cascade OCR results
|
||||||
|
-- This allows admins to view OCR recognition results from mobile devices
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ocr_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
bottle_id UUID REFERENCES bottles(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Image data
|
||||||
|
image_url TEXT, -- URL to the scanned image
|
||||||
|
image_thumbnail TEXT, -- Base64 thumbnail for quick preview
|
||||||
|
|
||||||
|
-- Detected fields
|
||||||
|
raw_text TEXT, -- All detected text joined
|
||||||
|
detected_texts JSONB, -- Array of individual text detections
|
||||||
|
|
||||||
|
-- Extracted data
|
||||||
|
distillery TEXT,
|
||||||
|
distillery_source TEXT, -- 'fuzzy', 'ai', 'manual'
|
||||||
|
bottle_name TEXT,
|
||||||
|
abv DECIMAL(5,2),
|
||||||
|
age INTEGER,
|
||||||
|
vintage TEXT,
|
||||||
|
volume TEXT,
|
||||||
|
category TEXT,
|
||||||
|
|
||||||
|
-- Meta
|
||||||
|
confidence INTEGER, -- 0-100
|
||||||
|
device_info TEXT, -- User agent or device type
|
||||||
|
ocr_method TEXT, -- 'text_detector', 'fallback', etc.
|
||||||
|
processing_time_ms INTEGER,
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for efficient queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ocr_logs_user_id ON ocr_logs(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ocr_logs_created_at ON ocr_logs(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ocr_logs_distillery ON ocr_logs(distillery);
|
||||||
|
|
||||||
|
-- RLS Policies
|
||||||
|
ALTER TABLE ocr_logs ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Users can view their own logs
|
||||||
|
CREATE POLICY "Users can view own ocr_logs"
|
||||||
|
ON ocr_logs FOR SELECT
|
||||||
|
USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- Users can insert their own logs
|
||||||
|
CREATE POLICY "Users can insert own ocr_logs"
|
||||||
|
ON ocr_logs FOR INSERT
|
||||||
|
WITH CHECK (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- Admins can view all logs
|
||||||
|
CREATE POLICY "Admins can view all ocr_logs"
|
||||||
|
ON ocr_logs FOR SELECT
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM admin_users
|
||||||
|
WHERE admin_users.user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Trigger for updated_at
|
||||||
|
CREATE OR REPLACE FUNCTION update_ocr_logs_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = now();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER trigger_ocr_logs_updated_at
|
||||||
|
BEFORE UPDATE ON ocr_logs
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_ocr_logs_updated_at();
|
||||||
36
sql/migrate_blind_tasting.sql
Normal file
36
sql/migrate_blind_tasting.sql
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
-- Add Blind Tasting support to Sessions
|
||||||
|
ALTER TABLE public.tasting_sessions
|
||||||
|
ADD COLUMN IF NOT EXISTS is_blind BOOLEAN DEFAULT false,
|
||||||
|
ADD COLUMN IF NOT EXISTS is_revealed BOOLEAN DEFAULT false;
|
||||||
|
|
||||||
|
-- Add Guessing fields to Tastings
|
||||||
|
ALTER TABLE public.tastings
|
||||||
|
ADD COLUMN IF NOT EXISTS blind_label TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS guess_abv DECIMAL,
|
||||||
|
ADD COLUMN IF NOT EXISTS guess_age INTEGER,
|
||||||
|
ADD COLUMN IF NOT EXISTS guess_region TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS guess_points INTEGER;
|
||||||
|
|
||||||
|
-- Update RLS Policies for blind sessions
|
||||||
|
-- Guests should only see bottle details if NOT blind OR revealed
|
||||||
|
-- This is a complex policy update, we'll refine the existing tastings_select_policy
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "tastings_select_policy" ON public.tastings;
|
||||||
|
CREATE POLICY "tastings_select_policy" ON public.tastings FOR SELECT USING (
|
||||||
|
-- You can see your own tastings
|
||||||
|
auth.uid() = user_id
|
||||||
|
OR
|
||||||
|
-- You can see tastings in a session you participate in
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM public.session_participants sp
|
||||||
|
JOIN public.buddies b ON b.id = sp.buddy_id
|
||||||
|
WHERE sp.session_id = public.tastings.session_id
|
||||||
|
AND b.buddy_profile_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Note: The logic for hiding bottle details will be handled in the UI/API layer
|
||||||
|
-- as the RLS here still needs to allow access to the tasting record itself.
|
||||||
|
-- Hiding 'bottle_id' content for blind tastings will be done in the frontend
|
||||||
|
-- based on session.is_blind and session.is_revealed.
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { GoogleGenerativeAI, SchemaType, HarmCategory, HarmBlockThreshold } from '@google/generative-ai';
|
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { trackApiUsage } from '@/services/track-api-usage';
|
import { trackApiUsage } from '@/services/track-api-usage';
|
||||||
import { deductCredits } from '@/services/credit-service';
|
import { deductCredits } from '@/services/credit-service';
|
||||||
@@ -8,32 +7,6 @@ import { getAllSystemTags } from '@/services/tags';
|
|||||||
import { getAIProvider, getOpenRouterClient, OPENROUTER_PROVIDER_PREFERENCES } from '@/lib/openrouter';
|
import { getAIProvider, getOpenRouterClient, OPENROUTER_PROVIDER_PREFERENCES } from '@/lib/openrouter';
|
||||||
import { getEnrichmentCache, saveEnrichmentCache, incrementCacheHit } from '@/services/cache-enrichment';
|
import { getEnrichmentCache, saveEnrichmentCache, incrementCacheHit } from '@/services/cache-enrichment';
|
||||||
|
|
||||||
// Native Schema Definition for Enrichment Data
|
|
||||||
const enrichmentSchema = {
|
|
||||||
description: "Sensory profile and search metadata for whisky",
|
|
||||||
type: SchemaType.OBJECT as const,
|
|
||||||
properties: {
|
|
||||||
suggested_tags: {
|
|
||||||
type: SchemaType.ARRAY,
|
|
||||||
description: "Array of suggested aroma/taste tags from the available system tags",
|
|
||||||
items: { type: SchemaType.STRING },
|
|
||||||
nullable: true
|
|
||||||
},
|
|
||||||
suggested_custom_tags: {
|
|
||||||
type: SchemaType.ARRAY,
|
|
||||||
description: "Array of custom dominant notes not in the system tags",
|
|
||||||
items: { type: SchemaType.STRING },
|
|
||||||
nullable: true
|
|
||||||
},
|
|
||||||
search_string: {
|
|
||||||
type: SchemaType.STRING,
|
|
||||||
description: "Optimized search query for Whiskybase discovery",
|
|
||||||
nullable: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
required: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const ENRICHMENT_MODEL = 'google/gemma-3-27b-it';
|
const ENRICHMENT_MODEL = 'google/gemma-3-27b-it';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -107,46 +80,11 @@ async function enrichWithOpenRouter(instruction: string): Promise<{ data: any; a
|
|||||||
throw lastError || new Error('OpenRouter enrichment failed after retries');
|
throw lastError || new Error('OpenRouter enrichment failed after retries');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Enrich with Gemini
|
|
||||||
*/
|
|
||||||
async function enrichWithGemini(instruction: string): Promise<{ data: any; apiTime: number; responseText: string }> {
|
|
||||||
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!);
|
|
||||||
const model = genAI.getGenerativeModel({
|
|
||||||
model: 'gemini-2.5-flash',
|
|
||||||
generationConfig: {
|
|
||||||
responseMimeType: "application/json",
|
|
||||||
responseSchema: enrichmentSchema as any,
|
|
||||||
temperature: 0.3,
|
|
||||||
},
|
|
||||||
safetySettings: [
|
|
||||||
{ category: HarmCategory.HARM_CATEGORY_HARASSMENT, threshold: HarmBlockThreshold.BLOCK_NONE },
|
|
||||||
{ category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, threshold: HarmBlockThreshold.BLOCK_NONE },
|
|
||||||
{ category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, threshold: HarmBlockThreshold.BLOCK_NONE },
|
|
||||||
{ category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold: HarmBlockThreshold.BLOCK_NONE },
|
|
||||||
] as any,
|
|
||||||
});
|
|
||||||
|
|
||||||
const startApi = performance.now();
|
|
||||||
const result = await model.generateContent(instruction);
|
|
||||||
const endApi = performance.now();
|
|
||||||
|
|
||||||
const responseText = result.response.text();
|
|
||||||
return {
|
|
||||||
data: JSON.parse(responseText),
|
|
||||||
apiTime: endApi - startApi,
|
|
||||||
responseText: responseText
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function enrichData(name: string, distillery: string, availableTags?: string, language: string = 'de') {
|
export async function enrichData(name: string, distillery: string, availableTags?: string, language: string = 'de') {
|
||||||
const provider = getAIProvider();
|
const provider = getAIProvider();
|
||||||
|
|
||||||
// Check API key based on provider
|
// Check API key
|
||||||
if (provider === 'gemini' && !process.env.GEMINI_API_KEY) {
|
if (!process.env.OPENROUTER_API_KEY) {
|
||||||
return { success: false, error: 'GEMINI_API_KEY is not configured.' };
|
|
||||||
}
|
|
||||||
if (provider === 'openrouter' && !process.env.OPENROUTER_API_KEY) {
|
|
||||||
return { success: false, error: 'OPENROUTER_API_KEY is not configured.' };
|
return { success: false, error: 'OPENROUTER_API_KEY is not configured.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,13 +141,8 @@ Instructions:
|
|||||||
3. Provide a clean "search_string" for Whiskybase (e.g., "Distillery Name Age").`;
|
3. Provide a clean "search_string" for Whiskybase (e.g., "Distillery Name Age").`;
|
||||||
|
|
||||||
console.log(`[EnrichData] Using provider: ${provider}`);
|
console.log(`[EnrichData] Using provider: ${provider}`);
|
||||||
let result: { data: any; apiTime: number; responseText: string };
|
|
||||||
|
|
||||||
if (provider === 'openrouter') {
|
const result = await enrichWithOpenRouter(instruction);
|
||||||
result = await enrichWithOpenRouter(instruction);
|
|
||||||
} else {
|
|
||||||
result = await enrichWithGemini(instruction);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[EnrichData] Response:', result.data);
|
console.log('[EnrichData] Response:', result.data);
|
||||||
|
|
||||||
@@ -229,7 +162,7 @@ Instructions:
|
|||||||
endpoint: `enrichData_${provider}`,
|
endpoint: `enrichData_${provider}`,
|
||||||
success: true,
|
success: true,
|
||||||
provider: provider,
|
provider: provider,
|
||||||
model: provider === 'openrouter' ? ENRICHMENT_MODEL : 'gemini-2.5-flash',
|
model: ENRICHMENT_MODEL,
|
||||||
responseText: result.responseText
|
responseText: result.responseText
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { GoogleGenerativeAI, SchemaType, HarmCategory, HarmBlockThreshold } from '@google/generative-ai';
|
|
||||||
import { BottleMetadataSchema, BottleMetadata } from '@/types/whisky';
|
import { BottleMetadataSchema, BottleMetadata } from '@/types/whisky';
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { trackApiUsage } from '@/services/track-api-usage';
|
import { trackApiUsage } from '@/services/track-api-usage';
|
||||||
@@ -9,30 +8,6 @@ import { getAIProvider, getOpenRouterClient, OPENROUTER_VISION_MODEL, OPENROUTER
|
|||||||
import { normalizeWhiskyData } from '@/lib/distillery-matcher';
|
import { normalizeWhiskyData } from '@/lib/distillery-matcher';
|
||||||
import { formatWhiskyName } from '@/utils/formatWhiskyName';
|
import { formatWhiskyName } from '@/utils/formatWhiskyName';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import sharp from 'sharp';
|
|
||||||
|
|
||||||
// Schema for AI extraction
|
|
||||||
const visionSchema = {
|
|
||||||
description: "Whisky bottle label metadata extracted from image",
|
|
||||||
type: SchemaType.OBJECT as const,
|
|
||||||
properties: {
|
|
||||||
name: { type: SchemaType.STRING, description: "Full whisky name (constructed)", nullable: false },
|
|
||||||
distillery: { type: SchemaType.STRING, description: "Distillery name", nullable: true },
|
|
||||||
bottler: { type: SchemaType.STRING, description: "Independent bottler if applicable", nullable: true },
|
|
||||||
series: { type: SchemaType.STRING, description: "Whisky series or collection (e.g. Cadenhead's Natural Strength)", nullable: true },
|
|
||||||
category: { type: SchemaType.STRING, description: "Whisky category (Single Malt, Blended, Bourbon, etc.)", nullable: true },
|
|
||||||
abv: { type: SchemaType.NUMBER, description: "Alcohol by volume percentage", nullable: true },
|
|
||||||
age: { type: SchemaType.NUMBER, description: "Age statement in years", nullable: true },
|
|
||||||
vintage: { type: SchemaType.STRING, description: "Vintage/distillation year", nullable: true },
|
|
||||||
cask_type: { type: SchemaType.STRING, description: "Cask type (Sherry, Bourbon, Port, etc.)", nullable: true },
|
|
||||||
distilled_at: { type: SchemaType.STRING, description: "Distillation date", nullable: true },
|
|
||||||
bottled_at: { type: SchemaType.STRING, description: "Bottling date", nullable: true },
|
|
||||||
batch_info: { type: SchemaType.STRING, description: "Batch or cask number", nullable: true },
|
|
||||||
is_whisky: { type: SchemaType.BOOLEAN, description: "Whether this is a whisky product", nullable: false },
|
|
||||||
confidence: { type: SchemaType.NUMBER, description: "Confidence score 0-1", nullable: false },
|
|
||||||
},
|
|
||||||
required: ["name", "is_whisky", "confidence"],
|
|
||||||
};
|
|
||||||
|
|
||||||
const VISION_PROMPT = `ROLE: Senior Whisky Database Curator.
|
const VISION_PROMPT = `ROLE: Senior Whisky Database Curator.
|
||||||
|
|
||||||
@@ -68,13 +43,11 @@ OUTPUT SCHEMA (Strict JSON):
|
|||||||
"confidence": number
|
"confidence": number
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
const GEMINI_MODEL = 'gemini-2.5-flash';
|
|
||||||
|
|
||||||
export interface ScannerResult {
|
export interface ScannerResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data?: BottleMetadata;
|
data?: BottleMetadata;
|
||||||
error?: string;
|
error?: string;
|
||||||
provider?: 'gemini' | 'openrouter';
|
provider?: 'openrouter';
|
||||||
perf?: {
|
perf?: {
|
||||||
imagePrep?: number;
|
imagePrep?: number;
|
||||||
apiCall: number;
|
apiCall: number;
|
||||||
@@ -183,7 +156,6 @@ export async function analyzeBottleLabel(imageBase64: string): Promise<ScannerRe
|
|||||||
console.log(`[Scanner] Using provider: ${provider}`);
|
console.log(`[Scanner] Using provider: ${provider}`);
|
||||||
let aiResult: { data: any; apiTime: number; responseText: string };
|
let aiResult: { data: any; apiTime: number; responseText: string };
|
||||||
|
|
||||||
if (provider === 'openrouter') {
|
|
||||||
const client = getOpenRouterClient();
|
const client = getOpenRouterClient();
|
||||||
const startApi = performance.now();
|
const startApi = performance.now();
|
||||||
const maxRetries = 3;
|
const maxRetries = 3;
|
||||||
@@ -234,34 +206,6 @@ export async function analyzeBottleLabel(imageBase64: string): Promise<ScannerRe
|
|||||||
apiTime: performance.now() - startApi,
|
apiTime: performance.now() - startApi,
|
||||||
responseText: content
|
responseText: content
|
||||||
};
|
};
|
||||||
} else {
|
|
||||||
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!);
|
|
||||||
const model = genAI.getGenerativeModel({
|
|
||||||
model: GEMINI_MODEL,
|
|
||||||
generationConfig: {
|
|
||||||
responseMimeType: "application/json",
|
|
||||||
responseSchema: visionSchema as any,
|
|
||||||
temperature: 0.1,
|
|
||||||
},
|
|
||||||
safetySettings: [
|
|
||||||
{ category: HarmCategory.HARM_CATEGORY_HARASSMENT, threshold: HarmBlockThreshold.BLOCK_NONE },
|
|
||||||
{ category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, threshold: HarmBlockThreshold.BLOCK_NONE },
|
|
||||||
{ category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, threshold: HarmBlockThreshold.BLOCK_NONE },
|
|
||||||
{ category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold: HarmBlockThreshold.BLOCK_NONE },
|
|
||||||
] as any,
|
|
||||||
});
|
|
||||||
const startApi = performance.now();
|
|
||||||
const result = await model.generateContent([
|
|
||||||
{ inlineData: { data: base64Data, mimeType } },
|
|
||||||
{ text: VISION_PROMPT },
|
|
||||||
]);
|
|
||||||
const responseText = result.response.text();
|
|
||||||
aiResult = {
|
|
||||||
data: JSON.parse(responseText),
|
|
||||||
apiTime: performance.now() - startApi,
|
|
||||||
responseText: responseText
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Name Composition & Normalization
|
// 6. Name Composition & Normalization
|
||||||
// Use standardized helper to construct the perfect name
|
// Use standardized helper to construct the perfect name
|
||||||
@@ -301,7 +245,7 @@ export async function analyzeBottleLabel(imageBase64: string): Promise<ScannerRe
|
|||||||
endpoint: `analyzeBottleLabel_${provider}`,
|
endpoint: `analyzeBottleLabel_${provider}`,
|
||||||
success: true,
|
success: true,
|
||||||
provider,
|
provider,
|
||||||
model: provider === 'openrouter' ? OPENROUTER_VISION_MODEL : GEMINI_MODEL,
|
model: OPENROUTER_VISION_MODEL,
|
||||||
responseText: aiResult.responseText
|
responseText: aiResult.responseText
|
||||||
});
|
});
|
||||||
await deductCredits(user.id, 'gemini_ai', `Scanner analysis (${provider})`);
|
await deductCredits(user.id, 'gemini_ai', `Scanner analysis (${provider})`);
|
||||||
|
|||||||
396
src/app/admin/banners/BannerManager.tsx
Normal file
396
src/app/admin/banners/BannerManager.tsx
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Image, ExternalLink, ToggleLeft, ToggleRight, Trash2, Plus, Edit2, Save, X, Loader2, Check } from 'lucide-react';
|
||||||
|
import { Banner, createBanner, updateBanner, toggleBannerActive, deleteBanner } from '@/services/banner-actions';
|
||||||
|
|
||||||
|
interface BannerManagerProps {
|
||||||
|
initialBanners: Banner[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BannerManager({ initialBanners }: BannerManagerProps) {
|
||||||
|
const [banners, setBanners] = useState<Banner[]>(initialBanners);
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState<string | null>(null);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
// Form states
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
title: '',
|
||||||
|
image_url: '',
|
||||||
|
link_target: '',
|
||||||
|
cta_text: 'Open',
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormData({ title: '', image_url: '', link_target: '', cta_text: 'Open' });
|
||||||
|
setShowCreateForm(false);
|
||||||
|
setEditingId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showMessage = (type: 'success' | 'error', text: string) => {
|
||||||
|
setMessage({ type, text });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading('create');
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('title', formData.title);
|
||||||
|
form.append('image_url', formData.image_url);
|
||||||
|
form.append('link_target', formData.link_target);
|
||||||
|
form.append('cta_text', formData.cta_text);
|
||||||
|
|
||||||
|
const result = await createBanner(form);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
showMessage('success', 'Banner created successfully');
|
||||||
|
// Refresh - in real app would revalidate
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
showMessage('error', result.error || 'Failed to create banner');
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(null);
|
||||||
|
resetForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = async (id: string) => {
|
||||||
|
setIsLoading(id);
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('title', formData.title);
|
||||||
|
form.append('image_url', formData.image_url);
|
||||||
|
form.append('link_target', formData.link_target);
|
||||||
|
form.append('cta_text', formData.cta_text);
|
||||||
|
|
||||||
|
const result = await updateBanner(id, form);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
showMessage('success', 'Banner updated successfully');
|
||||||
|
setBanners(banners.map(b =>
|
||||||
|
b.id === id
|
||||||
|
? { ...b, ...formData }
|
||||||
|
: b
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
showMessage('error', result.error || 'Failed to update banner');
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(null);
|
||||||
|
resetForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleActive = async (id: string, currentStatus: boolean) => {
|
||||||
|
setIsLoading(id);
|
||||||
|
|
||||||
|
const result = await toggleBannerActive(id, !currentStatus);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
showMessage('success', !currentStatus ? 'Banner activated' : 'Banner deactivated');
|
||||||
|
// If activating, deactivate all others
|
||||||
|
setBanners(banners.map(b => ({
|
||||||
|
...b,
|
||||||
|
is_active: b.id === id ? !currentStatus : (!currentStatus ? false : b.is_active)
|
||||||
|
})));
|
||||||
|
} else {
|
||||||
|
showMessage('error', result.error || 'Failed to toggle banner');
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this banner?')) return;
|
||||||
|
|
||||||
|
setIsLoading(id);
|
||||||
|
|
||||||
|
const result = await deleteBanner(id);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
showMessage('success', 'Banner deleted');
|
||||||
|
setBanners(banners.filter(b => b.id !== id));
|
||||||
|
} else {
|
||||||
|
showMessage('error', result.error || 'Failed to delete banner');
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEditing = (banner: Banner) => {
|
||||||
|
setEditingId(banner.id);
|
||||||
|
setFormData({
|
||||||
|
title: banner.title,
|
||||||
|
image_url: banner.image_url,
|
||||||
|
link_target: banner.link_target || '',
|
||||||
|
cta_text: banner.cta_text || 'Open',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Message Toast */}
|
||||||
|
{message && (
|
||||||
|
<div className={`fixed top-4 right-4 z-50 px-4 py-3 rounded-xl shadow-lg animate-in slide-in-from-right ${message.type === 'success'
|
||||||
|
? 'bg-green-500/20 border border-green-500/50 text-green-400'
|
||||||
|
: 'bg-red-500/20 border border-red-500/50 text-red-400'
|
||||||
|
}`}>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Button / Form */}
|
||||||
|
{!showCreateForm ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateForm(true)}
|
||||||
|
className="w-full py-4 bg-zinc-900 hover:bg-zinc-800 border border-dashed border-zinc-700 hover:border-orange-600/50 rounded-2xl text-zinc-400 hover:text-orange-500 transition-all flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus size={20} />
|
||||||
|
<span className="font-bold">Add New Banner</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleCreate} className="p-6 bg-zinc-900 rounded-2xl border border-zinc-800 space-y-4">
|
||||||
|
<h3 className="text-lg font-bold text-white mb-4">Create New Banner</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-zinc-400 mb-1">Title *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={e => setFormData({ ...formData, title: e.target.value })}
|
||||||
|
placeholder="Banner title"
|
||||||
|
className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-zinc-400 mb-1">Image URL * (16:9 recommended)</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={formData.image_url}
|
||||||
|
onChange={e => setFormData({ ...formData, image_url: e.target.value })}
|
||||||
|
placeholder="https://example.com/banner.jpg"
|
||||||
|
className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-zinc-400 mb-1">Link Target</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.link_target}
|
||||||
|
onChange={e => setFormData({ ...formData, link_target: e.target.value })}
|
||||||
|
placeholder="/sessions"
|
||||||
|
className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-zinc-400 mb-1">CTA Text</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.cta_text}
|
||||||
|
onChange={e => setFormData({ ...formData, cta_text: e.target.value })}
|
||||||
|
placeholder="Open"
|
||||||
|
className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
{formData.image_url && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<label className="block text-xs font-bold text-zinc-400 mb-2">Preview</label>
|
||||||
|
<div className="aspect-video rounded-xl overflow-hidden bg-zinc-800">
|
||||||
|
<img
|
||||||
|
src={formData.image_url}
|
||||||
|
alt="Preview"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder.png'; }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={resetForm}
|
||||||
|
className="flex-1 py-3 bg-zinc-800 text-zinc-400 rounded-xl font-bold hover:bg-zinc-700 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading === 'create'}
|
||||||
|
className="flex-1 py-3 bg-orange-600 hover:bg-orange-700 text-white rounded-xl font-bold disabled:opacity-50 flex items-center justify-center gap-2 transition-colors"
|
||||||
|
>
|
||||||
|
{isLoading === 'create' ? (
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Plus size={18} />
|
||||||
|
Create Banner
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Banners List */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{banners.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="w-16 h-16 mx-auto rounded-2xl bg-zinc-900 flex items-center justify-center mb-4">
|
||||||
|
<Image size={32} className="text-zinc-600" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-white mb-2">No Banners Yet</p>
|
||||||
|
<p className="text-sm text-zinc-500">Create your first banner to display on the home page.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
banners.map(banner => (
|
||||||
|
<div
|
||||||
|
key={banner.id}
|
||||||
|
className={`p-4 bg-zinc-900 rounded-2xl border transition-all ${banner.is_active
|
||||||
|
? 'border-green-600/50 ring-1 ring-green-600/20'
|
||||||
|
: 'border-zinc-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{editingId === banner.id ? (
|
||||||
|
/* Edit Form */
|
||||||
|
<div className="space-y-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={e => setFormData({ ...formData, title: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 bg-zinc-950 border border-zinc-700 rounded-lg text-white"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={formData.image_url}
|
||||||
|
onChange={e => setFormData({ ...formData, image_url: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 bg-zinc-950 border border-zinc-700 rounded-lg text-white"
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.link_target}
|
||||||
|
onChange={e => setFormData({ ...formData, link_target: e.target.value })}
|
||||||
|
placeholder="Link target"
|
||||||
|
className="px-3 py-2 bg-zinc-950 border border-zinc-700 rounded-lg text-white"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.cta_text}
|
||||||
|
onChange={e => setFormData({ ...formData, cta_text: e.target.value })}
|
||||||
|
placeholder="CTA text"
|
||||||
|
className="px-3 py-2 bg-zinc-950 border border-zinc-700 rounded-lg text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => resetForm()}
|
||||||
|
className="px-4 py-2 bg-zinc-800 text-zinc-400 rounded-lg"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleUpdate(banner.id)}
|
||||||
|
disabled={isLoading === banner.id}
|
||||||
|
className="flex-1 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg font-bold flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isLoading === banner.id ? (
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save size={16} />
|
||||||
|
Save
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Display Mode */
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{/* Thumbnail */}
|
||||||
|
<div className="w-32 h-20 rounded-lg overflow-hidden bg-zinc-800 shrink-0">
|
||||||
|
<img
|
||||||
|
src={banner.image_url}
|
||||||
|
alt={banner.title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h3 className="font-bold text-white truncate">{banner.title}</h3>
|
||||||
|
{banner.is_active && (
|
||||||
|
<span className="px-2 py-0.5 bg-green-600/20 text-green-400 text-[10px] font-bold uppercase rounded-full">
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{banner.link_target && (
|
||||||
|
<p className="text-xs text-zinc-500 flex items-center gap-1">
|
||||||
|
<ExternalLink size={12} />
|
||||||
|
{banner.link_target}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-zinc-600 mt-1">
|
||||||
|
CTA: {banner.cta_text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleActive(banner.id, banner.is_active)}
|
||||||
|
disabled={isLoading === banner.id}
|
||||||
|
className={`p-2 rounded-lg transition-colors ${banner.is_active
|
||||||
|
? 'bg-green-600/20 text-green-400 hover:bg-green-600/30'
|
||||||
|
: 'bg-zinc-800 text-zinc-500 hover:text-white'
|
||||||
|
}`}
|
||||||
|
title={banner.is_active ? 'Deactivate' : 'Activate'}
|
||||||
|
>
|
||||||
|
{isLoading === banner.id ? (
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
) : banner.is_active ? (
|
||||||
|
<ToggleRight size={18} />
|
||||||
|
) : (
|
||||||
|
<ToggleLeft size={18} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => startEditing(banner)}
|
||||||
|
className="p-2 bg-zinc-800 text-zinc-400 hover:text-white rounded-lg transition-colors"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Edit2 size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(banner.id)}
|
||||||
|
disabled={isLoading === banner.id}
|
||||||
|
className="p-2 bg-zinc-800 text-zinc-400 hover:text-red-500 rounded-lg transition-colors"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/app/admin/banners/page.tsx
Normal file
55
src/app/admin/banners/page.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { checkIsAdmin } from '@/services/track-api-usage';
|
||||||
|
import { getBanners } from '@/services/banner-actions';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ArrowLeft, Image, ExternalLink, ToggleLeft, ToggleRight, Trash2, Plus, Edit2 } from 'lucide-react';
|
||||||
|
import BannerManager from './BannerManager';
|
||||||
|
|
||||||
|
export default async function AdminBannersPage() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = await checkIsAdmin(user.id);
|
||||||
|
if (!isAdmin) {
|
||||||
|
redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { banners, error } = await getBanners();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-zinc-950 p-6 pb-24">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4 mb-8">
|
||||||
|
<Link
|
||||||
|
href="/admin"
|
||||||
|
className="p-2 rounded-xl bg-zinc-900 border border-zinc-800 text-zinc-400 hover:text-white hover:border-zinc-700 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
</Link>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="text-2xl font-bold text-white">Banner Management</h1>
|
||||||
|
<p className="text-sm text-zinc-500">
|
||||||
|
Manage hero banners for the home page
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 p-4 bg-red-500/20 border border-red-500/50 rounded-xl text-red-400 text-sm">
|
||||||
|
Error loading banners: {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Client Component for Interactive Banner Management */}
|
||||||
|
<BannerManager initialBanners={banners} />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
275
src/app/admin/bottles/AdminBottlesList.tsx
Normal file
275
src/app/admin/bottles/AdminBottlesList.tsx
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { Search, User, Wine, Star, X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Bottle {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
distillery: string | null;
|
||||||
|
image_url: string | null;
|
||||||
|
abv: number | null;
|
||||||
|
age: number | null;
|
||||||
|
category: string | null;
|
||||||
|
status: string | null;
|
||||||
|
created_at: string;
|
||||||
|
user_id: string;
|
||||||
|
tastings: { id: string; rating: number }[];
|
||||||
|
user: { username: string; display_name: string | null };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminBottlesListProps {
|
||||||
|
bottles: Bottle[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminBottlesList({ bottles }: AdminBottlesListProps) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [filterUser, setFilterUser] = useState<string | null>(null);
|
||||||
|
const [filterCategory, setFilterCategory] = useState<string | null>(null);
|
||||||
|
const [sortBy, setSortBy] = useState<'created_at' | 'name' | 'distillery' | 'rating'>('created_at');
|
||||||
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||||
|
|
||||||
|
// Get unique users and categories for filters
|
||||||
|
const users = useMemo(() => {
|
||||||
|
const userMap = new Map<string, string>();
|
||||||
|
bottles.forEach(b => {
|
||||||
|
userMap.set(b.user_id, b.user.display_name || b.user.username);
|
||||||
|
});
|
||||||
|
return Array.from(userMap.entries()).sort((a, b) => a[1].localeCompare(b[1]));
|
||||||
|
}, [bottles]);
|
||||||
|
|
||||||
|
const categories = useMemo(() => {
|
||||||
|
const cats = new Set<string>();
|
||||||
|
bottles.forEach(b => {
|
||||||
|
if (b.category) cats.add(b.category);
|
||||||
|
});
|
||||||
|
return Array.from(cats).sort();
|
||||||
|
}, [bottles]);
|
||||||
|
|
||||||
|
// Filter and sort bottles
|
||||||
|
const filteredBottles = useMemo(() => {
|
||||||
|
let result = bottles;
|
||||||
|
|
||||||
|
// Search filter
|
||||||
|
if (search) {
|
||||||
|
const searchLower = search.toLowerCase();
|
||||||
|
result = result.filter(b =>
|
||||||
|
b.name?.toLowerCase().includes(searchLower) ||
|
||||||
|
b.distillery?.toLowerCase().includes(searchLower) ||
|
||||||
|
b.user.username.toLowerCase().includes(searchLower) ||
|
||||||
|
b.user.display_name?.toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// User filter
|
||||||
|
if (filterUser) {
|
||||||
|
result = result.filter(b => b.user_id === filterUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category filter
|
||||||
|
if (filterCategory) {
|
||||||
|
result = result.filter(b => b.category === filterCategory);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
result = [...result].sort((a, b) => {
|
||||||
|
let comparison = 0;
|
||||||
|
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'name':
|
||||||
|
comparison = (a.name || '').localeCompare(b.name || '');
|
||||||
|
break;
|
||||||
|
case 'distillery':
|
||||||
|
comparison = (a.distillery || '').localeCompare(b.distillery || '');
|
||||||
|
break;
|
||||||
|
case 'rating':
|
||||||
|
const avgA = a.tastings?.length > 0
|
||||||
|
? a.tastings.reduce((sum, t) => sum + t.rating, 0) / a.tastings.length
|
||||||
|
: 0;
|
||||||
|
const avgB = b.tastings?.length > 0
|
||||||
|
? b.tastings.reduce((sum, t) => sum + t.rating, 0) / b.tastings.length
|
||||||
|
: 0;
|
||||||
|
comparison = avgA - avgB;
|
||||||
|
break;
|
||||||
|
case 'created_at':
|
||||||
|
default:
|
||||||
|
comparison = new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortOrder === 'asc' ? comparison : -comparison;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [bottles, search, filterUser, filterCategory, sortBy, sortOrder]);
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSearch('');
|
||||||
|
setFilterUser(null);
|
||||||
|
setFilterCategory(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasFilters = search || filterUser || filterCategory;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Search and Filters */}
|
||||||
|
<div className="flex flex-col lg:flex-row gap-4">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
placeholder="Search bottles, distilleries, or users..."
|
||||||
|
className="w-full pl-12 pr-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{/* User Filter */}
|
||||||
|
<select
|
||||||
|
value={filterUser || ''}
|
||||||
|
onChange={e => setFilterUser(e.target.value || null)}
|
||||||
|
className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-hidden focus:border-orange-600 appearance-none cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="">All Users</option>
|
||||||
|
{users.map(([id, name]) => (
|
||||||
|
<option key={id} value={id}>{name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Category Filter */}
|
||||||
|
<select
|
||||||
|
value={filterCategory || ''}
|
||||||
|
onChange={e => setFilterCategory(e.target.value || null)}
|
||||||
|
className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-hidden focus:border-orange-600 appearance-none cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="">All Categories</option>
|
||||||
|
{categories.map(cat => (
|
||||||
|
<option key={cat} value={cat}>{cat}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Sort */}
|
||||||
|
<select
|
||||||
|
value={`${sortBy}-${sortOrder}`}
|
||||||
|
onChange={e => {
|
||||||
|
const [by, order] = e.target.value.split('-') as [typeof sortBy, typeof sortOrder];
|
||||||
|
setSortBy(by);
|
||||||
|
setSortOrder(order);
|
||||||
|
}}
|
||||||
|
className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-hidden focus:border-orange-600 appearance-none cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="created_at-desc">Newest First</option>
|
||||||
|
<option value="created_at-asc">Oldest First</option>
|
||||||
|
<option value="name-asc">Name A-Z</option>
|
||||||
|
<option value="name-desc">Name Z-A</option>
|
||||||
|
<option value="distillery-asc">Distillery A-Z</option>
|
||||||
|
<option value="rating-desc">Highest Rating</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Clear Filters */}
|
||||||
|
{hasFilters && (
|
||||||
|
<button
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="px-4 py-3 bg-zinc-800 text-zinc-400 hover:text-white rounded-xl transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results Count */}
|
||||||
|
<div className="text-sm text-zinc-500">
|
||||||
|
Showing {filteredBottles.length} of {bottles.length} bottles
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottles Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{filteredBottles.map(bottle => {
|
||||||
|
const avgRating = bottle.tastings?.length > 0
|
||||||
|
? bottle.tastings.reduce((sum, t) => sum + t.rating, 0) / bottle.tastings.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={bottle.id}
|
||||||
|
className="bg-zinc-900 rounded-2xl border border-zinc-800 overflow-hidden hover:border-zinc-700 transition-colors"
|
||||||
|
>
|
||||||
|
{/* Image */}
|
||||||
|
<div className="aspect-4/3 relative bg-zinc-800">
|
||||||
|
{bottle.image_url ? (
|
||||||
|
<img
|
||||||
|
src={bottle.image_url}
|
||||||
|
alt={bottle.name || 'Bottle'}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<Wine size={48} className="text-zinc-700" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Category Badge */}
|
||||||
|
{bottle.category && (
|
||||||
|
<span className="absolute top-2 left-2 px-2 py-1 bg-black/60 backdrop-blur-xs text-[10px] font-bold text-white rounded-lg uppercase">
|
||||||
|
{bottle.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rating Badge */}
|
||||||
|
{avgRating > 0 && (
|
||||||
|
<span className="absolute top-2 right-2 px-2 py-1 bg-orange-600/90 backdrop-blur-xs text-xs font-bold text-white rounded-lg flex items-center gap-1">
|
||||||
|
<Star size={12} fill="currentColor" />
|
||||||
|
{avgRating.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="p-4">
|
||||||
|
<h3 className="font-bold text-white truncate mb-1">
|
||||||
|
{bottle.name || 'Unknown'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-zinc-500 truncate mb-3">
|
||||||
|
{bottle.distillery || 'Unknown Distillery'}
|
||||||
|
{bottle.age && ` • ${bottle.age}y`}
|
||||||
|
{bottle.abv && ` • ${bottle.abv}%`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* User & Date */}
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="flex items-center gap-1 text-zinc-400">
|
||||||
|
<User size={12} />
|
||||||
|
{bottle.user.display_name || bottle.user.username}
|
||||||
|
</span>
|
||||||
|
<span className="text-zinc-600">
|
||||||
|
{new Date(bottle.created_at).toLocaleDateString('de-DE')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{filteredBottles.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="w-16 h-16 mx-auto rounded-2xl bg-zinc-900 flex items-center justify-center mb-4">
|
||||||
|
<Wine size={32} className="text-zinc-600" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-white mb-2">No Bottles Found</p>
|
||||||
|
<p className="text-sm text-zinc-500">
|
||||||
|
{hasFilters ? 'Try adjusting your filters.' : 'No bottles have been scanned yet.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
src/app/admin/bottles/page.tsx
Normal file
160
src/app/admin/bottles/page.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { checkIsAdmin } from '@/services/track-api-usage';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ArrowLeft, Wine, User, Calendar, Star, Search, Filter } from 'lucide-react';
|
||||||
|
import AdminBottlesList from './AdminBottlesList';
|
||||||
|
|
||||||
|
export default async function AdminBottlesPage() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = await checkIsAdmin(user.id);
|
||||||
|
if (!isAdmin) {
|
||||||
|
redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all bottles from all users with user info
|
||||||
|
const { data: bottlesRaw, error } = await supabase
|
||||||
|
.from('bottles')
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
distillery,
|
||||||
|
image_url,
|
||||||
|
abv,
|
||||||
|
age,
|
||||||
|
category,
|
||||||
|
status,
|
||||||
|
created_at,
|
||||||
|
user_id,
|
||||||
|
tastings (
|
||||||
|
id,
|
||||||
|
rating
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(500);
|
||||||
|
|
||||||
|
// Get unique user IDs
|
||||||
|
const userIds = Array.from(new Set(bottlesRaw?.map(b => b.user_id) || []));
|
||||||
|
|
||||||
|
// Fetch profiles for these users
|
||||||
|
const { data: profiles } = userIds.length > 0
|
||||||
|
? await supabase.from('profiles').select('id, username, display_name').in('id', userIds)
|
||||||
|
: { data: [] };
|
||||||
|
|
||||||
|
// Combine bottles with user info
|
||||||
|
const bottles = bottlesRaw?.map(bottle => ({
|
||||||
|
...bottle,
|
||||||
|
user: profiles?.find(p => p.id === bottle.user_id) || { username: 'Unknown', display_name: null }
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
// Calculate stats
|
||||||
|
const stats = {
|
||||||
|
totalBottles: bottles.length,
|
||||||
|
totalUsers: userIds.length,
|
||||||
|
avgRating: bottles.reduce((sum, b) => {
|
||||||
|
const ratings = b.tastings?.map((t: any) => t.rating).filter((r: number) => r > 0) || [];
|
||||||
|
const avg = ratings.length > 0 ? ratings.reduce((a: number, b: number) => a + b, 0) / ratings.length : 0;
|
||||||
|
return sum + avg;
|
||||||
|
}, 0) / bottles.filter(b => b.tastings && b.tastings.length > 0).length || 0,
|
||||||
|
topDistilleries: Object.entries(
|
||||||
|
bottles.reduce((acc: Record<string, number>, b) => {
|
||||||
|
const d = b.distillery || 'Unknown';
|
||||||
|
acc[d] = (acc[d] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
|
).sort((a, b) => b[1] - a[1]).slice(0, 5),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-zinc-950 p-6 pb-24">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4 mb-8">
|
||||||
|
<Link
|
||||||
|
href="/admin"
|
||||||
|
className="p-2 rounded-xl bg-zinc-900 border border-zinc-800 text-zinc-400 hover:text-white hover:border-zinc-700 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
</Link>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="text-2xl font-bold text-white">All Bottles</h1>
|
||||||
|
<p className="text-sm text-zinc-500">
|
||||||
|
View all scanned bottles from all users
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 p-4 bg-red-500/20 border border-red-500/50 rounded-xl text-red-400 text-sm">
|
||||||
|
Error loading bottles: {error.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Wine size={18} className="text-orange-500" />
|
||||||
|
<span className="text-xs font-bold text-zinc-500 uppercase">Total Bottles</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white">{stats.totalBottles}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<User size={18} className="text-blue-500" />
|
||||||
|
<span className="text-xs font-bold text-zinc-500 uppercase">Total Users</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white">{stats.totalUsers}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Star size={18} className="text-yellow-500" />
|
||||||
|
<span className="text-xs font-bold text-zinc-500 uppercase">Avg Rating</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white">
|
||||||
|
{stats.avgRating > 0 ? stats.avgRating.toFixed(1) : 'N/A'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Calendar size={18} className="text-green-500" />
|
||||||
|
<span className="text-xs font-bold text-zinc-500 uppercase">Top Distillery</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-black text-white truncate">
|
||||||
|
{stats.topDistilleries[0]?.[0] || 'N/A'}
|
||||||
|
</div>
|
||||||
|
{stats.topDistilleries[0] && (
|
||||||
|
<div className="text-xs text-zinc-500">{stats.topDistilleries[0][1]} bottles</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Distilleries */}
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800 mb-8">
|
||||||
|
<h3 className="text-sm font-bold text-zinc-400 uppercase mb-3">Top 5 Distilleries</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{stats.topDistilleries.map(([name, count]) => (
|
||||||
|
<span
|
||||||
|
key={name}
|
||||||
|
className="px-3 py-1.5 bg-zinc-800 rounded-lg text-sm text-zinc-300"
|
||||||
|
>
|
||||||
|
{name} <span className="text-orange-500 font-bold">({count})</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottles List - Client Component for search/filter */}
|
||||||
|
<AdminBottlesList bottles={bottles} />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
254
src/app/admin/ocr-logs/page.tsx
Normal file
254
src/app/admin/ocr-logs/page.tsx
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { checkIsAdmin } from '@/services/track-api-usage';
|
||||||
|
import { getOcrLogs, getOcrStats } from '@/services/save-ocr-log';
|
||||||
|
import { Eye, Camera, TrendingUp, CheckCircle, AlertCircle, Calendar, Clock, Percent } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import Image from 'next/image';
|
||||||
|
|
||||||
|
export default async function OcrLogsPage() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = await checkIsAdmin(user.id);
|
||||||
|
if (!isAdmin) {
|
||||||
|
redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch OCR data
|
||||||
|
const [logsResult, stats] = await Promise.all([
|
||||||
|
getOcrLogs(100),
|
||||||
|
getOcrStats(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const logs = logsResult.data || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-zinc-50 dark:bg-black p-4 md:p-12">
|
||||||
|
<div className="max-w-7xl mx-auto space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-black text-zinc-900 dark:text-white tracking-tight">OCR Dashboard</h1>
|
||||||
|
<p className="text-zinc-500 mt-1">Mobile OCR Scan Results</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Link
|
||||||
|
href="/admin"
|
||||||
|
className="px-4 py-2 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded-xl font-bold hover:bg-zinc-800 transition-colors"
|
||||||
|
>
|
||||||
|
← Back to Admin
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
||||||
|
<Camera size={20} className="text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-black uppercase text-zinc-400">Total Scans</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-black text-zinc-900 dark:text-white">{stats.totalScans}</div>
|
||||||
|
<div className="text-xs text-zinc-500 mt-1">All time</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
|
||||||
|
<Calendar size={20} className="text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-black uppercase text-zinc-400">Today</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-black text-zinc-900 dark:text-white">{stats.todayScans}</div>
|
||||||
|
<div className="text-xs text-zinc-500 mt-1">Scans today</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
|
||||||
|
<Percent size={20} className="text-amber-600 dark:text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-black uppercase text-zinc-400">Avg Confidence</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-black text-zinc-900 dark:text-white">{stats.avgConfidence}%</div>
|
||||||
|
<div className="text-xs text-zinc-500 mt-1">Recognition quality</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||||
|
<TrendingUp size={20} className="text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-black uppercase text-zinc-400">Top Distillery</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-black text-zinc-900 dark:text-white truncate">
|
||||||
|
{stats.topDistilleries[0]?.name || '-'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-zinc-500 mt-1">
|
||||||
|
{stats.topDistilleries[0] ? `${stats.topDistilleries[0].count} scans` : 'No data'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Distilleries */}
|
||||||
|
{stats.topDistilleries.length > 0 && (
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
|
||||||
|
<h2 className="text-xl font-black text-zinc-900 dark:text-white mb-4">Most Scanned Distilleries</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{stats.topDistilleries.map((d, i) => (
|
||||||
|
<span
|
||||||
|
key={d.name}
|
||||||
|
className={`px-3 py-1.5 rounded-full text-sm font-bold ${i === 0
|
||||||
|
? 'bg-orange-600 text-white'
|
||||||
|
: 'bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{d.name} ({d.count})
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* OCR Logs Grid */}
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
|
||||||
|
<h2 className="text-xl font-black text-zinc-900 dark:text-white mb-4">Recent OCR Scans</h2>
|
||||||
|
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-zinc-500">
|
||||||
|
<Camera className="mx-auto mb-3" size={48} />
|
||||||
|
<p>No OCR scans recorded yet</p>
|
||||||
|
<p className="text-sm mt-1">Scans from mobile devices will appear here</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{logs.map((log: any) => (
|
||||||
|
<div
|
||||||
|
key={log.id}
|
||||||
|
className="bg-zinc-50 dark:bg-zinc-800/50 rounded-xl p-4 border border-zinc-200 dark:border-zinc-700 hover:border-orange-500/50 transition-colors"
|
||||||
|
>
|
||||||
|
{/* Image Preview */}
|
||||||
|
<div className="relative aspect-4/3 rounded-lg overflow-hidden bg-zinc-200 dark:bg-zinc-700 mb-3">
|
||||||
|
{log.image_thumbnail ? (
|
||||||
|
<img
|
||||||
|
src={log.image_thumbnail}
|
||||||
|
alt="Scan"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : log.image_url ? (
|
||||||
|
<img
|
||||||
|
src={log.image_url}
|
||||||
|
alt="Scan"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-zinc-400">
|
||||||
|
<Camera size={32} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Confidence Badge */}
|
||||||
|
<div className={`absolute top-2 right-2 px-2 py-1 rounded-full text-[10px] font-black ${log.confidence >= 70
|
||||||
|
? 'bg-green-500 text-white'
|
||||||
|
: log.confidence >= 40
|
||||||
|
? 'bg-amber-500 text-white'
|
||||||
|
: 'bg-red-500 text-white'
|
||||||
|
}`}>
|
||||||
|
{log.confidence}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detected Fields */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{log.distillery && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle size={14} className="text-green-500" />
|
||||||
|
<span className="text-sm font-bold text-zinc-900 dark:text-white">
|
||||||
|
{log.distillery}
|
||||||
|
</span>
|
||||||
|
{log.distillery_source && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 bg-zinc-200 dark:bg-zinc-700 rounded-sm text-zinc-500">
|
||||||
|
{log.distillery_source}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{log.bottle_name && (
|
||||||
|
<div className="text-sm text-zinc-600 dark:text-zinc-400 truncate">
|
||||||
|
{log.bottle_name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{log.abv && (
|
||||||
|
<span className="px-2 py-0.5 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400 rounded-sm text-[10px] font-bold">
|
||||||
|
{log.abv}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{log.age && (
|
||||||
|
<span className="px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 rounded-sm text-[10px] font-bold">
|
||||||
|
{log.age}y
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{log.vintage && (
|
||||||
|
<span className="px-2 py-0.5 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 rounded-sm text-[10px] font-bold">
|
||||||
|
{log.vintage}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{log.volume && (
|
||||||
|
<span className="px-2 py-0.5 bg-zinc-100 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-400 rounded-sm text-[10px] font-bold">
|
||||||
|
{log.volume}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Raw Text (Collapsible) */}
|
||||||
|
{log.raw_text && (
|
||||||
|
<details className="mt-3">
|
||||||
|
<summary className="text-[10px] font-bold text-zinc-400 cursor-pointer hover:text-orange-500 uppercase">
|
||||||
|
Raw Text
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-2 p-2 bg-zinc-100 dark:bg-zinc-900 rounded-sm text-[9px] text-zinc-500 overflow-x-auto max-h-20 whitespace-pre-wrap">
|
||||||
|
{log.raw_text}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Meta */}
|
||||||
|
<div className="flex items-center justify-between mt-3 pt-3 border-t border-zinc-200 dark:border-zinc-700">
|
||||||
|
<div className="flex items-center gap-1 text-[10px] text-zinc-400">
|
||||||
|
<Clock size={12} />
|
||||||
|
{new Date(log.created_at).toLocaleString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-zinc-400">
|
||||||
|
{log.profiles?.username || 'Unknown'}
|
||||||
|
</div>
|
||||||
|
{log.processing_time_ms && (
|
||||||
|
<div className="text-[10px] text-zinc-400">
|
||||||
|
{log.processing_time_ms}ms
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
export const dynamic = 'force-dynamic';
|
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { checkIsAdmin, getGlobalApiStats } from '@/services/track-api-usage';
|
import { checkIsAdmin, getGlobalApiStats } from '@/services/track-api-usage';
|
||||||
@@ -93,6 +92,12 @@ export default async function AdminPage() {
|
|||||||
<p className="text-zinc-500 mt-1">API Usage Monitoring & Statistics</p>
|
<p className="text-zinc-500 mt-1">API Usage Monitoring & Statistics</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
|
<Link
|
||||||
|
href="/admin/ocr-logs"
|
||||||
|
className="px-4 py-2 bg-cyan-600 hover:bg-cyan-700 text-white rounded-xl font-bold transition-colors"
|
||||||
|
>
|
||||||
|
OCR Logs
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/admin/plans"
|
href="/admin/plans"
|
||||||
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-xl font-bold transition-colors"
|
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-xl font-bold transition-colors"
|
||||||
@@ -111,6 +116,36 @@ export default async function AdminPage() {
|
|||||||
>
|
>
|
||||||
Manage Users
|
Manage Users
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/admin/banners"
|
||||||
|
className="px-4 py-2 bg-cyan-600 hover:bg-cyan-700 text-white rounded-xl font-bold transition-colors"
|
||||||
|
>
|
||||||
|
Manage Banners
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/admin/bottles"
|
||||||
|
className="px-4 py-2 bg-orange-600 hover:bg-orange-700 text-white rounded-xl font-bold transition-colors"
|
||||||
|
>
|
||||||
|
All Bottles
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/admin/splits"
|
||||||
|
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-xl font-bold transition-colors"
|
||||||
|
>
|
||||||
|
All Splits
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/admin/tastings"
|
||||||
|
className="px-4 py-2 bg-pink-600 hover:bg-pink-700 text-white rounded-xl font-bold transition-colors"
|
||||||
|
>
|
||||||
|
All Tastings
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/admin/sessions"
|
||||||
|
className="px-4 py-2 bg-teal-600 hover:bg-teal-700 text-white rounded-xl font-bold transition-colors"
|
||||||
|
>
|
||||||
|
All Sessions
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="px-4 py-2 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded-xl font-bold hover:bg-zinc-800 transition-colors"
|
className="px-4 py-2 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded-xl font-bold hover:bg-zinc-800 transition-colors"
|
||||||
@@ -122,7 +157,7 @@ export default async function AdminPage() {
|
|||||||
|
|
||||||
{/* Global Stats Cards */}
|
{/* Global Stats Cards */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
||||||
<BarChart3 size={20} className="text-blue-600 dark:text-blue-400" />
|
<BarChart3 size={20} className="text-blue-600 dark:text-blue-400" />
|
||||||
@@ -133,7 +168,7 @@ export default async function AdminPage() {
|
|||||||
<div className="text-xs text-zinc-500 mt-1">All time</div>
|
<div className="text-xs text-zinc-500 mt-1">All time</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
|
<div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
|
||||||
<Calendar size={20} className="text-green-600 dark:text-green-400" />
|
<Calendar size={20} className="text-green-600 dark:text-green-400" />
|
||||||
@@ -152,7 +187,7 @@ export default async function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
|
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
|
||||||
<TrendingUp size={20} className="text-amber-600 dark:text-amber-400" />
|
<TrendingUp size={20} className="text-amber-600 dark:text-amber-400" />
|
||||||
@@ -163,7 +198,7 @@ export default async function AdminPage() {
|
|||||||
<div className="text-xs text-zinc-500 mt-1">Whiskybase searches</div>
|
<div className="text-xs text-zinc-500 mt-1">Whiskybase searches</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||||
<Users size={20} className="text-purple-600 dark:text-purple-400" />
|
<Users size={20} className="text-purple-600 dark:text-purple-400" />
|
||||||
@@ -176,7 +211,7 @@ export default async function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top Users */}
|
{/* Top Users */}
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
|
||||||
<h2 className="text-xl font-black text-zinc-900 dark:text-white mb-4">Top Users by API Usage</h2>
|
<h2 className="text-xl font-black text-zinc-900 dark:text-white mb-4">Top Users by API Usage</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{topUsersWithStats.map((user, index) => (
|
{topUsersWithStats.map((user, index) => (
|
||||||
@@ -199,7 +234,7 @@ export default async function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent API Calls */}
|
{/* Recent API Calls */}
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
|
||||||
<h2 className="text-xl font-black text-zinc-900 dark:text-white mb-4">Recent API Calls</h2>
|
<h2 className="text-xl font-black text-zinc-900 dark:text-white mb-4">Recent API Calls</h2>
|
||||||
<div className="text-sm text-zinc-500 mb-4">
|
<div className="text-sm text-zinc-500 mb-4">
|
||||||
Total calls logged: {recentUsage?.length || 0}
|
Total calls logged: {recentUsage?.length || 0}
|
||||||
@@ -260,7 +295,7 @@ export default async function AdminPage() {
|
|||||||
{call.response_text && (
|
{call.response_text && (
|
||||||
<details className="text-[10px]">
|
<details className="text-[10px]">
|
||||||
<summary className="cursor-pointer text-orange-600 hover:text-orange-700 font-bold uppercase transition-colors">Response</summary>
|
<summary className="cursor-pointer text-orange-600 hover:text-orange-700 font-bold uppercase transition-colors">Response</summary>
|
||||||
<pre className="mt-2 p-2 bg-zinc-100 dark:bg-zinc-950 rounded border border-zinc-200 dark:border-zinc-800 overflow-x-auto max-w-sm whitespace-pre-wrap font-mono text-[9px] text-zinc-400">
|
<pre className="mt-2 p-2 bg-zinc-100 dark:bg-zinc-950 rounded-sm border border-zinc-200 dark:border-zinc-800 overflow-x-auto max-w-sm whitespace-pre-wrap font-mono text-[9px] text-zinc-400">
|
||||||
{call.response_text}
|
{call.response_text}
|
||||||
</pre>
|
</pre>
|
||||||
</details>
|
</details>
|
||||||
@@ -274,7 +309,7 @@ export default async function AdminPage() {
|
|||||||
<div className="group relative">
|
<div className="group relative">
|
||||||
<span className="text-red-600 dark:text-red-400 font-black text-xs cursor-help">ERR</span>
|
<span className="text-red-600 dark:text-red-400 font-black text-xs cursor-help">ERR</span>
|
||||||
{call.error_message && (
|
{call.error_message && (
|
||||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 w-48 p-2 bg-red-600 text-white text-[9px] rounded shadow-xl opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-50">
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 w-48 p-2 bg-red-600 text-white text-[9px] rounded-sm shadow-xl opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-50">
|
||||||
{call.error_message}
|
{call.error_message}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export const dynamic = 'force-dynamic';
|
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { checkIsAdmin } from '@/services/track-api-usage';
|
import { checkIsAdmin } from '@/services/track-api-usage';
|
||||||
|
|||||||
220
src/app/admin/sessions/AdminSessionsList.tsx
Normal file
220
src/app/admin/sessions/AdminSessionsList.tsx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { Search, User, Calendar, GlassWater, Users, Check, X, Clock, ExternalLink } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
user_id: string;
|
||||||
|
scheduled_at: string;
|
||||||
|
ended_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
user: { username: string; display_name: string | null };
|
||||||
|
participantCount: number;
|
||||||
|
tastingCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminSessionsListProps {
|
||||||
|
sessions: Session[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminSessionsList({ sessions }: AdminSessionsListProps) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [filterHost, setFilterHost] = useState<string | null>(null);
|
||||||
|
const [filterStatus, setFilterStatus] = useState<'all' | 'active' | 'ended'>('all');
|
||||||
|
|
||||||
|
// Get unique hosts for filter
|
||||||
|
const hosts = useMemo(() => {
|
||||||
|
const hostMap = new Map<string, string>();
|
||||||
|
sessions.forEach(s => {
|
||||||
|
hostMap.set(s.user_id, s.user.display_name || s.user.username);
|
||||||
|
});
|
||||||
|
return Array.from(hostMap.entries()).sort((a, b) => a[1].localeCompare(b[1]));
|
||||||
|
}, [sessions]);
|
||||||
|
|
||||||
|
// Filter sessions
|
||||||
|
const filteredSessions = useMemo(() => {
|
||||||
|
let result = sessions;
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
const searchLower = search.toLowerCase();
|
||||||
|
result = result.filter(s =>
|
||||||
|
s.name?.toLowerCase().includes(searchLower) ||
|
||||||
|
s.user.username.toLowerCase().includes(searchLower) ||
|
||||||
|
s.user.display_name?.toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterHost) {
|
||||||
|
result = result.filter(s => s.user_id === filterHost);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterStatus === 'active') {
|
||||||
|
result = result.filter(s => !s.ended_at);
|
||||||
|
} else if (filterStatus === 'ended') {
|
||||||
|
result = result.filter(s => s.ended_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [sessions, search, filterHost, filterStatus]);
|
||||||
|
|
||||||
|
const formatDate = (date: string) => {
|
||||||
|
return new Date(date).toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSessionDuration = (start: string, end: string | null) => {
|
||||||
|
const startDate = new Date(start);
|
||||||
|
const endDate = end ? new Date(end) : new Date();
|
||||||
|
const diffMs = endDate.getTime() - startDate.getTime();
|
||||||
|
const hours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||||
|
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes}m`;
|
||||||
|
}
|
||||||
|
return `${minutes}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-col lg:flex-row gap-4">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
placeholder="Search sessions or hosts..."
|
||||||
|
className="w-full pl-12 pr-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
value={filterHost || ''}
|
||||||
|
onChange={e => setFilterHost(e.target.value || null)}
|
||||||
|
className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-hidden"
|
||||||
|
>
|
||||||
|
<option value="">All Hosts</option>
|
||||||
|
{hosts.map(([id, name]) => (
|
||||||
|
<option key={id} value={id}>{name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={filterStatus}
|
||||||
|
onChange={e => setFilterStatus(e.target.value as any)}
|
||||||
|
className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-hidden"
|
||||||
|
>
|
||||||
|
<option value="all">All Status</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="ended">Ended</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
<div className="text-sm text-zinc-500">
|
||||||
|
Showing {filteredSessions.length} of {sessions.length} sessions
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sessions List */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredSessions.map(session => (
|
||||||
|
<div
|
||||||
|
key={session.id}
|
||||||
|
className={`bg-zinc-900 rounded-2xl border p-4 transition-colors ${!session.ended_at
|
||||||
|
? 'border-orange-600/30 hover:border-orange-600/50'
|
||||||
|
: 'border-zinc-800 hover:border-zinc-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Icon */}
|
||||||
|
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${!session.ended_at
|
||||||
|
? 'bg-orange-600/20'
|
||||||
|
: 'bg-zinc-800'
|
||||||
|
}`}>
|
||||||
|
<GlassWater size={24} className={
|
||||||
|
!session.ended_at ? 'text-orange-500' : 'text-zinc-500'
|
||||||
|
} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h3 className="font-bold text-white truncate">{session.name}</h3>
|
||||||
|
{!session.ended_at ? (
|
||||||
|
<span className="px-2 py-0.5 bg-orange-600/20 text-orange-500 text-[10px] font-bold uppercase rounded-full flex items-center gap-1">
|
||||||
|
<span className="w-1.5 h-1.5 bg-orange-500 rounded-full animate-pulse" />
|
||||||
|
Live
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="px-2 py-0.5 bg-zinc-800 text-zinc-500 text-[10px] font-bold uppercase rounded-full flex items-center gap-1">
|
||||||
|
<Check size={10} />
|
||||||
|
Ended
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-xs text-zinc-500">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<User size={12} />
|
||||||
|
{session.user.display_name || session.user.username}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar size={12} />
|
||||||
|
{formatDate(session.scheduled_at)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock size={12} />
|
||||||
|
{getSessionDuration(session.scheduled_at, session.ended_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg font-bold text-white">{session.participantCount}</div>
|
||||||
|
<div className="text-[10px] text-zinc-600 uppercase">Buddies</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg font-bold text-orange-500">{session.tastingCount}</div>
|
||||||
|
<div className="text-[10px] text-zinc-600 uppercase">Tastings</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Link */}
|
||||||
|
<Link
|
||||||
|
href={`/sessions/${session.id}`}
|
||||||
|
target="_blank"
|
||||||
|
className="p-2 text-zinc-500 hover:text-white hover:bg-zinc-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink size={18} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{filteredSessions.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="w-16 h-16 mx-auto rounded-2xl bg-zinc-900 flex items-center justify-center mb-4">
|
||||||
|
<Calendar size={32} className="text-zinc-600" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-white mb-2">No Sessions Found</p>
|
||||||
|
<p className="text-sm text-zinc-500">No tasting sessions match your filters.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
src/app/admin/sessions/page.tsx
Normal file
132
src/app/admin/sessions/page.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { checkIsAdmin } from '@/services/track-api-usage';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ArrowLeft, Calendar, User, Users, GlassWater, Clock, CheckCircle } from 'lucide-react';
|
||||||
|
import AdminSessionsList from './AdminSessionsList';
|
||||||
|
|
||||||
|
export default async function AdminSessionsPage() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = await checkIsAdmin(user.id);
|
||||||
|
if (!isAdmin) {
|
||||||
|
redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all sessions from all users
|
||||||
|
const { data: sessionsRaw, error } = await supabase
|
||||||
|
.from('tasting_sessions')
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
user_id,
|
||||||
|
scheduled_at,
|
||||||
|
ended_at,
|
||||||
|
created_at,
|
||||||
|
session_participants (id),
|
||||||
|
tastings (id)
|
||||||
|
`)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(500);
|
||||||
|
|
||||||
|
// Get unique user IDs
|
||||||
|
const userIds = Array.from(new Set(sessionsRaw?.map(s => s.user_id) || []));
|
||||||
|
|
||||||
|
// Fetch profiles for users
|
||||||
|
const { data: profiles } = userIds.length > 0
|
||||||
|
? await supabase.from('profiles').select('id, username, display_name').in('id', userIds)
|
||||||
|
: { data: [] };
|
||||||
|
|
||||||
|
// Combine sessions with user info
|
||||||
|
const sessions = sessionsRaw?.map(session => ({
|
||||||
|
...session,
|
||||||
|
user: profiles?.find(p => p.id === session.user_id) || { username: 'Unknown', display_name: null },
|
||||||
|
participantCount: (session.session_participants as any[])?.length || 0,
|
||||||
|
tastingCount: (session.tastings as any[])?.length || 0,
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
// Calculate stats
|
||||||
|
const stats = {
|
||||||
|
totalSessions: sessions.length,
|
||||||
|
activeSessions: sessions.filter(s => !s.ended_at).length,
|
||||||
|
totalHosts: userIds.length,
|
||||||
|
totalParticipants: sessions.reduce((sum, s) => sum + s.participantCount, 0),
|
||||||
|
totalTastings: sessions.reduce((sum, s) => sum + s.tastingCount, 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-zinc-950 p-6 pb-24">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4 mb-8">
|
||||||
|
<Link
|
||||||
|
href="/admin"
|
||||||
|
className="p-2 rounded-xl bg-zinc-900 border border-zinc-800 text-zinc-400 hover:text-white hover:border-zinc-700 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
</Link>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="text-2xl font-bold text-white">All Tasting Sessions</h1>
|
||||||
|
<p className="text-sm text-zinc-500">
|
||||||
|
View all tasting sessions from all users
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 p-4 bg-red-500/20 border border-red-500/50 rounded-xl text-red-400 text-sm">
|
||||||
|
Error loading sessions: {error.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Calendar size={18} className="text-purple-500" />
|
||||||
|
<span className="text-xs font-bold text-zinc-500 uppercase">Total Sessions</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white">{stats.totalSessions}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Clock size={18} className="text-green-500" />
|
||||||
|
<span className="text-xs font-bold text-zinc-500 uppercase">Active</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white">{stats.activeSessions}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<User size={18} className="text-blue-500" />
|
||||||
|
<span className="text-xs font-bold text-zinc-500 uppercase">Hosts</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white">{stats.totalHosts}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Users size={18} className="text-orange-500" />
|
||||||
|
<span className="text-xs font-bold text-zinc-500 uppercase">Participants</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white">{stats.totalParticipants}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<GlassWater size={18} className="text-yellow-500" />
|
||||||
|
<span className="text-xs font-bold text-zinc-500 uppercase">Tastings</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white">{stats.totalTastings}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sessions List */}
|
||||||
|
<AdminSessionsList sessions={sessions} />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
222
src/app/admin/splits/AdminSplitsList.tsx
Normal file
222
src/app/admin/splits/AdminSplitsList.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { Search, User, Share2, Users, Check, X, ExternalLink } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface Split {
|
||||||
|
id: string;
|
||||||
|
public_slug: string;
|
||||||
|
host_id: string;
|
||||||
|
total_volume: number;
|
||||||
|
host_share: number;
|
||||||
|
price_bottle: number;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
host: { username: string; display_name: string | null };
|
||||||
|
bottle: { id: string; name: string; distillery: string | null; image_url: string | null } | null;
|
||||||
|
participantCount: number;
|
||||||
|
totalReserved: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminSplitsListProps {
|
||||||
|
splits: Split[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminSplitsList({ splits }: AdminSplitsListProps) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [filterHost, setFilterHost] = useState<string | null>(null);
|
||||||
|
const [filterStatus, setFilterStatus] = useState<'all' | 'active' | 'closed'>('all');
|
||||||
|
|
||||||
|
// Get unique hosts for filter
|
||||||
|
const hosts = useMemo(() => {
|
||||||
|
const hostMap = new Map<string, string>();
|
||||||
|
splits.forEach(s => {
|
||||||
|
hostMap.set(s.host_id, s.host.display_name || s.host.username);
|
||||||
|
});
|
||||||
|
return Array.from(hostMap.entries()).sort((a, b) => a[1].localeCompare(b[1]));
|
||||||
|
}, [splits]);
|
||||||
|
|
||||||
|
// Filter splits
|
||||||
|
const filteredSplits = useMemo(() => {
|
||||||
|
let result = splits;
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
const searchLower = search.toLowerCase();
|
||||||
|
result = result.filter(s =>
|
||||||
|
s.bottle?.name?.toLowerCase().includes(searchLower) ||
|
||||||
|
s.bottle?.distillery?.toLowerCase().includes(searchLower) ||
|
||||||
|
s.host.username.toLowerCase().includes(searchLower) ||
|
||||||
|
s.public_slug.toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterHost) {
|
||||||
|
result = result.filter(s => s.host_id === filterHost);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterStatus === 'active') {
|
||||||
|
result = result.filter(s => s.is_active);
|
||||||
|
} else if (filterStatus === 'closed') {
|
||||||
|
result = result.filter(s => !s.is_active);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [splits, search, filterHost, filterStatus]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-col lg:flex-row gap-4">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
placeholder="Search bottles, hosts, or slugs..."
|
||||||
|
className="w-full pl-12 pr-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
value={filterHost || ''}
|
||||||
|
onChange={e => setFilterHost(e.target.value || null)}
|
||||||
|
className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-hidden"
|
||||||
|
>
|
||||||
|
<option value="">All Hosts</option>
|
||||||
|
{hosts.map(([id, name]) => (
|
||||||
|
<option key={id} value={id}>{name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={filterStatus}
|
||||||
|
onChange={e => setFilterStatus(e.target.value as any)}
|
||||||
|
className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-hidden"
|
||||||
|
>
|
||||||
|
<option value="all">All Status</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="closed">Closed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
<div className="text-sm text-zinc-500">
|
||||||
|
Showing {filteredSplits.length} of {splits.length} splits
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Splits Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{filteredSplits.map(split => {
|
||||||
|
const available = split.total_volume - split.host_share;
|
||||||
|
const remaining = available - split.totalReserved;
|
||||||
|
const fillPercent = Math.min(100, (split.totalReserved / available) * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={split.id}
|
||||||
|
className={`bg-zinc-900 rounded-2xl border overflow-hidden transition-colors ${split.is_active
|
||||||
|
? 'border-zinc-800 hover:border-zinc-700'
|
||||||
|
: 'border-zinc-800/50 opacity-60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Image */}
|
||||||
|
<div className="aspect-video relative bg-zinc-800">
|
||||||
|
{split.bottle?.image_url ? (
|
||||||
|
<img
|
||||||
|
src={split.bottle.image_url}
|
||||||
|
alt={split.bottle.name || 'Bottle'}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<Share2 size={48} className="text-zinc-700" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status Badge */}
|
||||||
|
<span className={`absolute top-2 left-2 px-2 py-1 text-[10px] font-bold rounded-lg flex items-center gap-1 ${split.is_active
|
||||||
|
? 'bg-green-600/90 text-white'
|
||||||
|
: 'bg-zinc-700/90 text-zinc-300'
|
||||||
|
}`}>
|
||||||
|
{split.is_active ? <Check size={10} /> : <X size={10} />}
|
||||||
|
{split.is_active ? 'Active' : 'Closed'}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Participants Badge */}
|
||||||
|
<span className="absolute top-2 right-2 px-2 py-1 bg-black/60 backdrop-blur-xs text-xs font-bold text-white rounded-lg flex items-center gap-1">
|
||||||
|
<Users size={12} />
|
||||||
|
{split.participantCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="p-4">
|
||||||
|
<h3 className="font-bold text-white truncate mb-1">
|
||||||
|
{split.bottle?.name || 'Unknown Bottle'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-zinc-500 truncate mb-2">
|
||||||
|
{split.bottle?.distillery || 'Unknown Distillery'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="flex justify-between text-xs text-zinc-500 mb-1">
|
||||||
|
<span>{split.totalReserved}cl reserved</span>
|
||||||
|
<span>{remaining}cl left</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-zinc-800 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-linear-to-r from-orange-500 to-orange-600 transition-all"
|
||||||
|
style={{ width: `${fillPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details */}
|
||||||
|
<div className="flex items-center justify-between text-xs mb-3">
|
||||||
|
<span className="flex items-center gap-1 text-zinc-400">
|
||||||
|
<User size={12} />
|
||||||
|
{split.host.display_name || split.host.username}
|
||||||
|
</span>
|
||||||
|
<span className="text-zinc-600">
|
||||||
|
{new Date(split.created_at).toLocaleDateString('de-DE')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price & Link */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-bold text-orange-500">
|
||||||
|
{split.price_bottle.toFixed(2)}€
|
||||||
|
</span>
|
||||||
|
<Link
|
||||||
|
href={`/splits/${split.public_slug}`}
|
||||||
|
target="_blank"
|
||||||
|
className="flex items-center gap-1 text-xs text-zinc-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink size={12} />
|
||||||
|
{split.public_slug}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{filteredSplits.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="w-16 h-16 mx-auto rounded-2xl bg-zinc-900 flex items-center justify-center mb-4">
|
||||||
|
<Share2 size={32} className="text-zinc-600" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-white mb-2">No Splits Found</p>
|
||||||
|
<p className="text-sm text-zinc-500">No bottle splits match your filters.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
src/app/admin/splits/page.tsx
Normal file
138
src/app/admin/splits/page.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { checkIsAdmin } from '@/services/track-api-usage';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ArrowLeft, Share2, User, Calendar, Users, DollarSign, Package } from 'lucide-react';
|
||||||
|
import AdminSplitsList from './AdminSplitsList';
|
||||||
|
|
||||||
|
export default async function AdminSplitsPage() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = await checkIsAdmin(user.id);
|
||||||
|
if (!isAdmin) {
|
||||||
|
redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all splits from all users
|
||||||
|
const { data: splitsRaw, error } = await supabase
|
||||||
|
.from('bottle_splits')
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
public_slug,
|
||||||
|
bottle_id,
|
||||||
|
host_id,
|
||||||
|
total_volume,
|
||||||
|
host_share,
|
||||||
|
price_bottle,
|
||||||
|
is_active,
|
||||||
|
created_at,
|
||||||
|
bottles (id, name, distillery, image_url),
|
||||||
|
split_participants (id, amount_cl, status, user_id)
|
||||||
|
`)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(500);
|
||||||
|
|
||||||
|
// Get unique host IDs
|
||||||
|
const hostIds = Array.from(new Set(splitsRaw?.map(s => s.host_id) || []));
|
||||||
|
|
||||||
|
// Fetch profiles for hosts
|
||||||
|
const { data: profiles } = hostIds.length > 0
|
||||||
|
? await supabase.from('profiles').select('id, username, display_name').in('id', hostIds)
|
||||||
|
: { data: [] };
|
||||||
|
|
||||||
|
// Combine splits with host info
|
||||||
|
const splits = splitsRaw?.map(split => ({
|
||||||
|
...split,
|
||||||
|
host: profiles?.find(p => p.id === split.host_id) || { username: 'Unknown', display_name: null },
|
||||||
|
bottle: split.bottles as any,
|
||||||
|
participantCount: (split.split_participants as any[])?.length || 0,
|
||||||
|
totalReserved: (split.split_participants as any[])?.reduce((sum: number, p: any) =>
|
||||||
|
['APPROVED', 'PAID', 'SHIPPED', 'PENDING'].includes(p.status) ? sum + p.amount_cl : sum, 0
|
||||||
|
) || 0,
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
// Calculate stats
|
||||||
|
const stats = {
|
||||||
|
totalSplits: splits.length,
|
||||||
|
activeSplits: splits.filter(s => s.is_active).length,
|
||||||
|
totalHosts: hostIds.length,
|
||||||
|
totalParticipants: splits.reduce((sum, s) => sum + s.participantCount, 0),
|
||||||
|
totalVolume: splits.reduce((sum, s) => sum + s.total_volume, 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-zinc-950 p-6 pb-24">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4 mb-8">
|
||||||
|
<Link
|
||||||
|
href="/admin"
|
||||||
|
className="p-2 rounded-xl bg-zinc-900 border border-zinc-800 text-zinc-400 hover:text-white hover:border-zinc-700 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
</Link>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="text-2xl font-bold text-white">All Bottle Splits</h1>
|
||||||
|
<p className="text-sm text-zinc-500">
|
||||||
|
View all bottle splits from all users
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 p-4 bg-red-500/20 border border-red-500/50 rounded-xl text-red-400 text-sm">
|
||||||
|
Error loading splits: {error.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Share2 size={18} className="text-purple-500" />
|
||||||
|
<span className="text-xs font-bold text-zinc-500 uppercase">Total Splits</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white">{stats.totalSplits}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Package size={18} className="text-green-500" />
|
||||||
|
<span className="text-xs font-bold text-zinc-500 uppercase">Active</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white">{stats.activeSplits}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<User size={18} className="text-blue-500" />
|
||||||
|
<span className="text-xs font-bold text-zinc-500 uppercase">Hosts</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white">{stats.totalHosts}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Users size={18} className="text-orange-500" />
|
||||||
|
<span className="text-xs font-bold text-zinc-500 uppercase">Participants</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white">{stats.totalParticipants}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<DollarSign size={18} className="text-yellow-500" />
|
||||||
|
<span className="text-xs font-bold text-zinc-500 uppercase">Total Volume</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white">{stats.totalVolume}cl</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Splits List */}
|
||||||
|
<AdminSplitsList splits={splits} />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -88,7 +88,7 @@ export default function AdminTagsPage() {
|
|||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
placeholder="Tags suchen..."
|
placeholder="Tags suchen..."
|
||||||
className="w-full pl-10 pr-4 py-2 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-amber-500 outline-none transition-all dark:text-zinc-200"
|
className="w-full pl-10 pr-4 py-2 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-amber-500 outline-hidden transition-all dark:text-zinc-200"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -96,7 +96,7 @@ export default function AdminTagsPage() {
|
|||||||
<select
|
<select
|
||||||
value={categoryFilter}
|
value={categoryFilter}
|
||||||
onChange={(e) => setCategoryFilter(e.target.value as any)}
|
onChange={(e) => setCategoryFilter(e.target.value as any)}
|
||||||
className="bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl px-3 py-2 text-sm font-bold uppercase tracking-tight outline-none focus:ring-2 focus:ring-amber-500 dark:text-zinc-200"
|
className="bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl px-3 py-2 text-sm font-bold uppercase tracking-tight outline-hidden focus:ring-2 focus:ring-amber-500 dark:text-zinc-200"
|
||||||
>
|
>
|
||||||
<option value="all">Alle Kategorien</option>
|
<option value="all">Alle Kategorien</option>
|
||||||
<option value="nose">Nose</option>
|
<option value="nose">Nose</option>
|
||||||
@@ -154,7 +154,7 @@ export default function AdminTagsPage() {
|
|||||||
key={score}
|
key={score}
|
||||||
onClick={() => updatePopularity(tag.id, score)}
|
onClick={() => updatePopularity(tag.id, score)}
|
||||||
className={`w-6 h-6 rounded-md flex items-center justify-center text-[10px] font-black transition-all ${tag.popularity_score === score
|
className={`w-6 h-6 rounded-md flex items-center justify-center text-[10px] font-black transition-all ${tag.popularity_score === score
|
||||||
? 'bg-amber-600 text-white shadow-sm'
|
? 'bg-amber-600 text-white shadow-xs'
|
||||||
: 'bg-zinc-100 text-zinc-400 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700'
|
: 'bg-zinc-100 text-zinc-400 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
226
src/app/admin/tastings/AdminTastingsList.tsx
Normal file
226
src/app/admin/tastings/AdminTastingsList.tsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { Search, User, Star, Wine, MessageSquare } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Tasting {
|
||||||
|
id: string;
|
||||||
|
bottle_id: string;
|
||||||
|
user_id: string;
|
||||||
|
rating: number;
|
||||||
|
nose_notes: string | null;
|
||||||
|
palate_notes: string | null;
|
||||||
|
finish_notes: string | null;
|
||||||
|
created_at: string;
|
||||||
|
user: { username: string; display_name: string | null };
|
||||||
|
bottle: { id: string; name: string; distillery: string | null; image_url: string | null } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminTastingsListProps {
|
||||||
|
tastings: Tasting[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminTastingsList({ tastings }: AdminTastingsListProps) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [filterUser, setFilterUser] = useState<string | null>(null);
|
||||||
|
const [filterRating, setFilterRating] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Get unique users for filter
|
||||||
|
const users = useMemo(() => {
|
||||||
|
const userMap = new Map<string, string>();
|
||||||
|
tastings.forEach(t => {
|
||||||
|
userMap.set(t.user_id, t.user.display_name || t.user.username);
|
||||||
|
});
|
||||||
|
return Array.from(userMap.entries()).sort((a, b) => a[1].localeCompare(b[1]));
|
||||||
|
}, [tastings]);
|
||||||
|
|
||||||
|
// Filter tastings
|
||||||
|
const filteredTastings = useMemo(() => {
|
||||||
|
let result = tastings;
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
const searchLower = search.toLowerCase();
|
||||||
|
result = result.filter(t =>
|
||||||
|
t.bottle?.name?.toLowerCase().includes(searchLower) ||
|
||||||
|
t.bottle?.distillery?.toLowerCase().includes(searchLower) ||
|
||||||
|
t.user.username.toLowerCase().includes(searchLower) ||
|
||||||
|
t.nose_notes?.toLowerCase().includes(searchLower) ||
|
||||||
|
t.palate_notes?.toLowerCase().includes(searchLower) ||
|
||||||
|
t.finish_notes?.toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterUser) {
|
||||||
|
result = result.filter(t => t.user_id === filterUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterRating !== null) {
|
||||||
|
result = result.filter(t => Math.floor(t.rating) === filterRating);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [tastings, search, filterUser, filterRating]);
|
||||||
|
|
||||||
|
const renderStars = (rating: number) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
{[1, 2, 3, 4, 5].map(star => (
|
||||||
|
<Star
|
||||||
|
key={star}
|
||||||
|
size={14}
|
||||||
|
className={star <= rating ? 'text-orange-500 fill-orange-500' : 'text-zinc-700'}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-col lg:flex-row gap-4">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
placeholder="Search bottles, users, or notes..."
|
||||||
|
className="w-full pl-12 pr-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
value={filterUser || ''}
|
||||||
|
onChange={e => setFilterUser(e.target.value || null)}
|
||||||
|
className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-hidden"
|
||||||
|
>
|
||||||
|
<option value="">All Users</option>
|
||||||
|
{users.map(([id, name]) => (
|
||||||
|
<option key={id} value={id}>{name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={filterRating ?? ''}
|
||||||
|
onChange={e => setFilterRating(e.target.value ? parseInt(e.target.value) : null)}
|
||||||
|
className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-hidden"
|
||||||
|
>
|
||||||
|
<option value="">All Ratings</option>
|
||||||
|
<option value="5">5 Stars</option>
|
||||||
|
<option value="4">4 Stars</option>
|
||||||
|
<option value="3">3 Stars</option>
|
||||||
|
<option value="2">2 Stars</option>
|
||||||
|
<option value="1">1 Star</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
<div className="text-sm text-zinc-500">
|
||||||
|
Showing {filteredTastings.length} of {tastings.length} tastings
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tastings List */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredTastings.map(tasting => {
|
||||||
|
const hasNotes = tasting.nose_notes || tasting.palate_notes || tasting.finish_notes;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tasting.id}
|
||||||
|
className="bg-zinc-900 rounded-2xl border border-zinc-800 p-4 hover:border-zinc-700 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{/* Bottle Image */}
|
||||||
|
<div className="w-16 h-16 rounded-xl overflow-hidden bg-zinc-800 shrink-0">
|
||||||
|
{tasting.bottle?.image_url ? (
|
||||||
|
<img
|
||||||
|
src={tasting.bottle.image_url}
|
||||||
|
alt={tasting.bottle.name || 'Bottle'}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<Wine size={24} className="text-zinc-700" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-1">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-white truncate">
|
||||||
|
{tasting.bottle?.name || 'Unknown Bottle'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-zinc-500 truncate">
|
||||||
|
{tasting.bottle?.distillery || 'Unknown Distillery'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0">
|
||||||
|
{tasting.rating > 0 ? renderStars(tasting.rating) : (
|
||||||
|
<span className="text-xs text-zinc-600">No rating</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes Preview */}
|
||||||
|
{hasNotes && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{tasting.nose_notes && (
|
||||||
|
<p className="text-xs text-zinc-400">
|
||||||
|
<span className="text-zinc-600">Nose:</span> {tasting.nose_notes.slice(0, 80)}...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{tasting.palate_notes && (
|
||||||
|
<p className="text-xs text-zinc-400">
|
||||||
|
<span className="text-zinc-600">Palate:</span> {tasting.palate_notes.slice(0, 80)}...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Meta */}
|
||||||
|
<div className="flex items-center gap-4 mt-2 text-xs text-zinc-600">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<User size={12} />
|
||||||
|
{tasting.user.display_name || tasting.user.username}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{new Date(tasting.created_at).toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{hasNotes && (
|
||||||
|
<span className="flex items-center gap-1 text-green-500">
|
||||||
|
<MessageSquare size={12} />
|
||||||
|
Has notes
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{filteredTastings.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="w-16 h-16 mx-auto rounded-2xl bg-zinc-900 flex items-center justify-center mb-4">
|
||||||
|
<Star size={32} className="text-zinc-600" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-white mb-2">No Tastings Found</p>
|
||||||
|
<p className="text-sm text-zinc-500">No tastings match your filters.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
src/app/admin/tastings/page.tsx
Normal file
140
src/app/admin/tastings/page.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { checkIsAdmin } from '@/services/track-api-usage';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ArrowLeft, Wine, User, Calendar, Star, MessageSquare, Sparkles } from 'lucide-react';
|
||||||
|
import AdminTastingsList from './AdminTastingsList';
|
||||||
|
|
||||||
|
export default async function AdminTastingsPage() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = await checkIsAdmin(user.id);
|
||||||
|
if (!isAdmin) {
|
||||||
|
redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all tastings from all users
|
||||||
|
const { data: tastingsRaw, error } = await supabase
|
||||||
|
.from('tastings')
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
bottle_id,
|
||||||
|
user_id,
|
||||||
|
rating,
|
||||||
|
nose_notes,
|
||||||
|
palate_notes,
|
||||||
|
finish_notes,
|
||||||
|
created_at,
|
||||||
|
bottles (id, name, distillery, image_url)
|
||||||
|
`)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(500);
|
||||||
|
|
||||||
|
// Get unique user IDs
|
||||||
|
const userIds = Array.from(new Set(tastingsRaw?.map(t => t.user_id) || []));
|
||||||
|
|
||||||
|
// Fetch profiles for users
|
||||||
|
const { data: profiles } = userIds.length > 0
|
||||||
|
? await supabase.from('profiles').select('id, username, display_name').in('id', userIds)
|
||||||
|
: { data: [] };
|
||||||
|
|
||||||
|
// Combine tastings with user info
|
||||||
|
const tastings = tastingsRaw?.map(tasting => ({
|
||||||
|
...tasting,
|
||||||
|
user: profiles?.find(p => p.id === tasting.user_id) || { username: 'Unknown', display_name: null },
|
||||||
|
bottle: tasting.bottles as any,
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
// Calculate stats
|
||||||
|
const stats = {
|
||||||
|
totalTastings: tastings.length,
|
||||||
|
totalUsers: userIds.length,
|
||||||
|
avgRating: tastings.length > 0
|
||||||
|
? tastings.reduce((sum, t) => sum + (t.rating || 0), 0) / tastings.filter(t => t.rating > 0).length
|
||||||
|
: 0,
|
||||||
|
withNotes: tastings.filter(t => t.nose_notes || t.palate_notes || t.finish_notes).length,
|
||||||
|
todayCount: tastings.filter(t => {
|
||||||
|
const today = new Date();
|
||||||
|
const tastingDate = new Date(t.created_at);
|
||||||
|
return tastingDate.toDateString() === today.toDateString();
|
||||||
|
}).length,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-zinc-950 p-6 pb-24">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4 mb-8">
|
||||||
|
<Link
|
||||||
|
href="/admin"
|
||||||
|
className="p-2 rounded-xl bg-zinc-900 border border-zinc-800 text-zinc-400 hover:text-white hover:border-zinc-700 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
</Link>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="text-2xl font-bold text-white">All Tastings</h1>
|
||||||
|
<p className="text-sm text-zinc-500">
|
||||||
|
View all tasting notes from all users
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 p-4 bg-red-500/20 border border-red-500/50 rounded-xl text-red-400 text-sm">
|
||||||
|
Error loading tastings: {error.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Sparkles size={18} className="text-purple-500" />
|
||||||
|
<span className="text-xs font-bold text-zinc-500 uppercase">Total Tastings</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white">{stats.totalTastings}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<User size={18} className="text-blue-500" />
|
||||||
|
<span className="text-xs font-bold text-zinc-500 uppercase">Users</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white">{stats.totalUsers}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Star size={18} className="text-yellow-500" />
|
||||||
|
<span className="text-xs font-bold text-zinc-500 uppercase">Avg Rating</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white">
|
||||||
|
{stats.avgRating > 0 ? stats.avgRating.toFixed(1) : 'N/A'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<MessageSquare size={18} className="text-green-500" />
|
||||||
|
<span className="text-xs font-bold text-zinc-500 uppercase">With Notes</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white">{stats.withNotes}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Calendar size={18} className="text-orange-500" />
|
||||||
|
<span className="text-xs font-bold text-zinc-500 uppercase">Today</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white">{stats.todayCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tastings List */}
|
||||||
|
<AdminTastingsList tastings={tastings} />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
export const dynamic = 'force-dynamic';
|
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { checkIsAdmin } from '@/services/track-api-usage';
|
import { checkIsAdmin } from '@/services/track-api-usage';
|
||||||
@@ -53,7 +52,7 @@ export default async function AdminUsersPage() {
|
|||||||
|
|
||||||
{/* Statistics Cards */}
|
{/* Statistics Cards */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
||||||
<Users size={20} className="text-blue-600 dark:text-blue-400" />
|
<Users size={20} className="text-blue-600 dark:text-blue-400" />
|
||||||
@@ -63,7 +62,7 @@ export default async function AdminUsersPage() {
|
|||||||
<div className="text-3xl font-black text-zinc-900 dark:text-white">{totalUsers}</div>
|
<div className="text-3xl font-black text-zinc-900 dark:text-white">{totalUsers}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
|
<div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
|
||||||
<Coins size={20} className="text-green-600 dark:text-green-400" />
|
<Coins size={20} className="text-green-600 dark:text-green-400" />
|
||||||
@@ -73,7 +72,7 @@ export default async function AdminUsersPage() {
|
|||||||
<div className="text-3xl font-black text-zinc-900 dark:text-white">{totalCreditsInCirculation.toLocaleString()}</div>
|
<div className="text-3xl font-black text-zinc-900 dark:text-white">{totalCreditsInCirculation.toLocaleString()}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
|
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
|
||||||
<TrendingUp size={20} className="text-amber-600 dark:text-amber-400" />
|
<TrendingUp size={20} className="text-amber-600 dark:text-amber-400" />
|
||||||
@@ -83,7 +82,7 @@ export default async function AdminUsersPage() {
|
|||||||
<div className="text-3xl font-black text-zinc-900 dark:text-white">{totalCreditsPurchased.toLocaleString()}</div>
|
<div className="text-3xl font-black text-zinc-900 dark:text-white">{totalCreditsPurchased.toLocaleString()}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||||
<TrendingDown size={20} className="text-purple-600 dark:text-purple-400" />
|
<TrendingDown size={20} className="text-purple-600 dark:text-purple-400" />
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export const dynamic = 'force-dynamic';
|
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
|||||||
85
src/app/api/glitchtip-tunnel/route.ts
Normal file
85
src/app/api/glitchtip-tunnel/route.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GlitchTip/Sentry Tunnel API Route
|
||||||
|
*
|
||||||
|
* This tunnels error reports from the client through our own API,
|
||||||
|
* bypassing ad blockers that might block direct Sentry/GlitchTip requests.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const dsn = process.env.NEXT_PUBLIC_GLITCHTIP_DSN;
|
||||||
|
|
||||||
|
console.log('[GlitchTip Tunnel] Received request');
|
||||||
|
console.log('[GlitchTip Tunnel] DSN:', dsn ? dsn.substring(0, 40) + '...' : 'NOT SET');
|
||||||
|
|
||||||
|
if (!dsn) {
|
||||||
|
console.error('[GlitchTip Tunnel] No DSN configured');
|
||||||
|
return NextResponse.json(
|
||||||
|
{ status: 'error', message: 'GlitchTip not configured' },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.text();
|
||||||
|
console.log('[GlitchTip Tunnel] Body length:', body.length);
|
||||||
|
console.log('[GlitchTip Tunnel] Body preview:', body.substring(0, 200));
|
||||||
|
|
||||||
|
// Parse the envelope header to get the DSN from the actual request
|
||||||
|
// Sentry SDK sends: {"dsn":"...","sent_at":"..."}
|
||||||
|
const envelopeHeader = body.split('\n')[0];
|
||||||
|
let targetDsn = dsn;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headerData = JSON.parse(envelopeHeader);
|
||||||
|
if (headerData.dsn) {
|
||||||
|
targetDsn = headerData.dsn;
|
||||||
|
console.log('[GlitchTip Tunnel] Using DSN from envelope:', targetDsn.substring(0, 40) + '...');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.log('[GlitchTip Tunnel] Could not parse envelope header, using env DSN');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the DSN to extract components
|
||||||
|
// DSN format: https://<key>@<host>/<project_id>
|
||||||
|
const dsnUrl = new URL(targetDsn);
|
||||||
|
const key = dsnUrl.username;
|
||||||
|
const host = dsnUrl.host;
|
||||||
|
const projectId = dsnUrl.pathname.replace('/', '');
|
||||||
|
|
||||||
|
// GlitchTip uses the same API as Sentry
|
||||||
|
const glitchtipUrl = `https://${host}/api/${projectId}/envelope/`;
|
||||||
|
|
||||||
|
console.log('[GlitchTip Tunnel] Forwarding to:', glitchtipUrl);
|
||||||
|
|
||||||
|
const response = await fetch(glitchtipUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-sentry-envelope',
|
||||||
|
'X-Sentry-Auth': `Sentry sentry_version=7, sentry_client=sentry.javascript.nextjs, sentry_key=${key}`,
|
||||||
|
},
|
||||||
|
body: body,
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseText = await response.text();
|
||||||
|
console.log('[GlitchTip Tunnel] Response status:', response.status);
|
||||||
|
console.log('[GlitchTip Tunnel] Response body:', responseText.substring(0, 200));
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('[GlitchTip Tunnel] Error response:', response.status, responseText);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ status: 'error', message: 'Failed to forward to GlitchTip', details: responseText },
|
||||||
|
{ status: response.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[GlitchTip Tunnel] ✅ Successfully forwarded to GlitchTip');
|
||||||
|
return NextResponse.json({ status: 'ok' });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[GlitchTip Tunnel] Exception:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ status: 'error', message: error.message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
export const dynamic = 'force-dynamic';
|
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export const dynamic = 'force-dynamic';
|
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
|||||||
259
src/app/buddies/page.tsx
Normal file
259
src/app/buddies/page.tsx
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { ArrowLeft, Users, UserPlus, Loader2, Trash2, Link2, Search } from 'lucide-react';
|
||||||
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import { addBuddy, deleteBuddy } from '@/services/buddy';
|
||||||
|
import BuddyHandshake from '@/components/BuddyHandshake';
|
||||||
|
|
||||||
|
interface Buddy {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
buddy_profile_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BuddiesPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const supabase = createClient();
|
||||||
|
const { user, isLoading: isAuthLoading } = useAuth();
|
||||||
|
|
||||||
|
const [buddies, setBuddies] = useState<Buddy[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [isHandshakeOpen, setIsHandshakeOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthLoading && user) {
|
||||||
|
fetchBuddies();
|
||||||
|
}
|
||||||
|
}, [user, isAuthLoading]);
|
||||||
|
|
||||||
|
const fetchBuddies = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('buddies')
|
||||||
|
.select('*')
|
||||||
|
.order('name');
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
setBuddies(data || []);
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddBuddy = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newName.trim()) return;
|
||||||
|
|
||||||
|
setIsAdding(true);
|
||||||
|
const result = await addBuddy({ name: newName.trim() });
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setBuddies(prev => [...[result.data], ...prev].sort((a, b) => a.name.localeCompare(b.name)));
|
||||||
|
setNewName('');
|
||||||
|
}
|
||||||
|
setIsAdding(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteBuddy = async (id: string) => {
|
||||||
|
const result = await deleteBuddy(id);
|
||||||
|
if (result.success) {
|
||||||
|
setBuddies(prev => prev.filter(b => b.id !== id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredBuddies = buddies.filter(b =>
|
||||||
|
b.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const linkedBuddies = filteredBuddies.filter(b => b.buddy_profile_id);
|
||||||
|
const unlinkedBuddies = filteredBuddies.filter(b => !b.buddy_profile_id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-zinc-950 pb-24">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="sticky top-0 z-20 bg-zinc-950/95 backdrop-blur-md border-b border-zinc-900">
|
||||||
|
<div className="max-w-2xl mx-auto px-4 py-4 flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="p-2 rounded-xl bg-zinc-900 border border-zinc-800 text-zinc-400 hover:text-white hover:border-zinc-700 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
</button>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="text-xl font-bold text-white">
|
||||||
|
{t('buddy.title')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
{buddies.length} Buddies
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsHandshakeOpen(true)}
|
||||||
|
className="p-2.5 bg-zinc-900 hover:bg-zinc-800 border border-zinc-800 hover:border-orange-600/50 rounded-xl text-orange-500 transition-colors"
|
||||||
|
>
|
||||||
|
<Link2 size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-2xl mx-auto px-4 pt-6">
|
||||||
|
{/* Add Buddy Form */}
|
||||||
|
<form onSubmit={handleAddBuddy} className="flex gap-2 mb-6">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
placeholder={t('buddy.placeholder')}
|
||||||
|
className="flex-1 bg-zinc-900 border border-zinc-800 rounded-xl px-4 py-3 text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600 transition-colors"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isAdding || !newName.trim()}
|
||||||
|
className="px-4 bg-orange-600 hover:bg-orange-700 text-white rounded-xl transition-all disabled:opacity-50 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{isAdding ? (
|
||||||
|
<Loader2 size={20} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UserPlus size={20} />
|
||||||
|
<span className="hidden sm:inline text-sm font-bold">Add</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
{buddies.length > 5 && (
|
||||||
|
<div className="relative mb-6">
|
||||||
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search buddies..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-9 pr-4 py-2.5 bg-zinc-900 border border-zinc-800 rounded-xl text-sm text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Buddies List */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-20">
|
||||||
|
<Loader2 size={32} className="animate-spin text-orange-600" />
|
||||||
|
</div>
|
||||||
|
) : buddies.length === 0 ? (
|
||||||
|
<div className="text-center py-20">
|
||||||
|
<div className="w-16 h-16 mx-auto rounded-2xl bg-zinc-900 flex items-center justify-center mb-4">
|
||||||
|
<Users size={32} className="text-zinc-600" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-white mb-2">{t('buddy.noBuddies')}</p>
|
||||||
|
<p className="text-sm text-zinc-500 max-w-xs mx-auto">
|
||||||
|
Add your tasting friends to share sessions and compare notes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Linked Buddies */}
|
||||||
|
{linkedBuddies.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-[10px] font-bold uppercase tracking-widest text-orange-500/80 mb-3 flex items-center gap-2">
|
||||||
|
<Link2 size={12} />
|
||||||
|
Linked Accounts
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
{linkedBuddies.map(buddy => (
|
||||||
|
<BuddyCard
|
||||||
|
key={buddy.id}
|
||||||
|
buddy={buddy}
|
||||||
|
onDelete={handleDeleteBuddy}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Unlinked Buddies */}
|
||||||
|
{unlinkedBuddies.length > 0 && (
|
||||||
|
<div>
|
||||||
|
{linkedBuddies.length > 0 && (
|
||||||
|
<h3 className="text-[10px] font-bold uppercase tracking-widest text-zinc-500 mb-3">
|
||||||
|
Other Buddies
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
{unlinkedBuddies.map(buddy => (
|
||||||
|
<BuddyCard
|
||||||
|
key={buddy.id}
|
||||||
|
buddy={buddy}
|
||||||
|
onDelete={handleDeleteBuddy}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Buddy Handshake Dialog */}
|
||||||
|
<BuddyHandshake
|
||||||
|
isOpen={isHandshakeOpen}
|
||||||
|
onClose={() => setIsHandshakeOpen(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setIsHandshakeOpen(false);
|
||||||
|
fetchBuddies();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BuddyCard({ buddy, onDelete }: { buddy: Buddy; onDelete: (id: string) => void }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
setIsDeleting(true);
|
||||||
|
await onDelete(buddy.id);
|
||||||
|
setIsDeleting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between p-4 bg-zinc-900 rounded-2xl border border-zinc-800 group hover:border-zinc-700 transition-all">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-11 h-11 rounded-xl flex items-center justify-center font-bold text-lg ${buddy.buddy_profile_id
|
||||||
|
? 'bg-orange-600/20 text-orange-500 border border-orange-600/30'
|
||||||
|
: 'bg-zinc-800 text-zinc-400 border border-zinc-700'
|
||||||
|
}`}>
|
||||||
|
{buddy.name[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-white">{buddy.name}</p>
|
||||||
|
{buddy.buddy_profile_id && (
|
||||||
|
<p className="text-[9px] font-bold uppercase tracking-widest text-orange-500/80">
|
||||||
|
{t('common.link')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="p-2 text-zinc-600 hover:text-red-500 hover:bg-zinc-800 rounded-xl opacity-0 group-hover:opacity-100 transition-all"
|
||||||
|
>
|
||||||
|
{isDeleting ? (
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 size={16} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { AlertTriangle, RefreshCcw } from 'lucide-react';
|
import { AlertTriangle, RefreshCcw } from 'lucide-react';
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
export default function Error({
|
export default function Error({
|
||||||
error,
|
error,
|
||||||
@@ -12,8 +13,11 @@ export default function Error({
|
|||||||
}) {
|
}) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.error('App Crash Error:', error);
|
console.error('App Crash Error:', error);
|
||||||
|
// Report error to Sentry/GlitchTip
|
||||||
|
Sentry.captureException(error);
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col items-center justify-center p-6 bg-zinc-50 dark:bg-black text-center">
|
<div className="flex min-h-screen flex-col items-center justify-center p-6 bg-zinc-50 dark:bg-black text-center">
|
||||||
<div className="bg-white dark:bg-zinc-900 p-8 rounded-3xl border border-zinc-200 dark:border-zinc-800 shadow-xl max-w-md w-full space-y-6">
|
<div className="bg-white dark:bg-zinc-900 p-8 rounded-3xl border border-zinc-200 dark:border-zinc-800 shadow-xl max-w-md w-full space-y-6">
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { RefreshCcw } from 'lucide-react';
|
import { RefreshCcw } from 'lucide-react';
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
export default function GlobalError({
|
export default function GlobalError({
|
||||||
error,
|
error,
|
||||||
@@ -9,6 +11,11 @@ export default function GlobalError({
|
|||||||
error: Error & { digest?: string };
|
error: Error & { digest?: string };
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
// Report error to Sentry/GlitchTip
|
||||||
|
Sentry.captureException(error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,6 +1,58 @@
|
|||||||
@tailwind base;
|
@import 'tailwindcss';
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
@theme {
|
||||||
|
--color-zinc-50: #fafafa;
|
||||||
|
--color-zinc-100: #f4f4f5;
|
||||||
|
--color-zinc-200: #e4e4e7;
|
||||||
|
--color-zinc-300: #d4d4d8;
|
||||||
|
--color-zinc-400: #a8a8b3;
|
||||||
|
--color-zinc-500: #8a8a95;
|
||||||
|
--color-zinc-600: #6b6b75;
|
||||||
|
--color-zinc-700: #4a4a52;
|
||||||
|
--color-zinc-800: #2a2a2e;
|
||||||
|
--color-zinc-900: #1a1a1e;
|
||||||
|
--color-zinc-950: #0d0d0f;
|
||||||
|
|
||||||
|
--background-image-gradient-radial: radial-gradient(var(--tw-gradient-stops));
|
||||||
|
--background-image-gradient-conic: conic-gradient(
|
||||||
|
from 180deg at 50% 50%,
|
||||||
|
var(--tw-gradient-stops)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||||
|
so we've added these compatibility styles to make sure everything still
|
||||||
|
looks the same as it did with Tailwind CSS v3.
|
||||||
|
|
||||||
|
If we ever want to remove these styles, we need to add an explicit border
|
||||||
|
color utility to any element that depends on these defaults.
|
||||||
|
*/
|
||||||
|
@layer base {
|
||||||
|
*,
|
||||||
|
::after,
|
||||||
|
::before,
|
||||||
|
::backdrop,
|
||||||
|
::file-selector-button {
|
||||||
|
border-color: var(--color-gray-200, currentcolor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility glass {
|
||||||
|
@apply backdrop-blur-md bg-zinc-900/50 border border-zinc-800/50;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility glass-dark {
|
||||||
|
@apply backdrop-blur-md bg-zinc-950/80 border border-zinc-900/50;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility scrollbar-hide {
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
@@ -20,16 +72,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
body {
|
body {
|
||||||
@apply bg-[#1c1c1e] text-[#fafafa] antialiased selection:bg-orange-500/30;
|
@apply bg-[#1c1c1e] text-[#fafafa] antialiased selection:bg-orange-500/30;
|
||||||
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
|
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Global Input Text Fix */
|
/* Global Input Text Fix */
|
||||||
input,
|
input,
|
||||||
textarea,
|
textarea,
|
||||||
select {
|
select {
|
||||||
@apply bg-zinc-950 text-white border-zinc-800 focus:ring-1 focus:ring-orange-600 outline-none transition-all;
|
@apply bg-zinc-950 text-white border-zinc-800 focus:ring-1 focus:ring-orange-600 outline-hidden transition-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
input::placeholder,
|
input::placeholder,
|
||||||
@@ -45,22 +98,4 @@ h4,
|
|||||||
font-family: var(--font-inter), system-ui, sans-serif;
|
font-family: var(--font-inter), system-ui, sans-serif;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
|
||||||
.glass {
|
|
||||||
@apply backdrop-blur-md bg-zinc-900/50 border border-zinc-800/50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.glass-dark {
|
|
||||||
@apply backdrop-blur-md bg-zinc-950/80 border border-zinc-900/50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scrollbar-hide::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scrollbar-hide {
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,8 @@ import MainContentWrapper from "@/components/MainContentWrapper";
|
|||||||
import SyncHandler from "@/components/SyncHandler";
|
import SyncHandler from "@/components/SyncHandler";
|
||||||
import CookieBanner from "@/components/CookieBanner";
|
import CookieBanner from "@/components/CookieBanner";
|
||||||
import OnboardingTutorial from "@/components/OnboardingTutorial";
|
import OnboardingTutorial from "@/components/OnboardingTutorial";
|
||||||
|
import BackgroundRemovalHandler from "@/components/BackgroundRemovalHandler";
|
||||||
|
import SentryInit from "@/components/SentryInit";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"], variable: '--font-inter' });
|
const inter = Inter({ subsets: ["latin"], variable: '--font-inter' });
|
||||||
|
|
||||||
@@ -48,12 +50,15 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="de" suppressHydrationWarning={true}>
|
<html lang="de" suppressHydrationWarning={true}>
|
||||||
<body className={`${inter.variable} font-sans`}>
|
<body className={`${inter.variable} font-sans`}>
|
||||||
|
<SentryInit />
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
|
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
<ActiveSessionBanner />
|
<ActiveSessionBanner />
|
||||||
<MainContentWrapper>
|
<MainContentWrapper>
|
||||||
<SyncHandler />
|
<SyncHandler />
|
||||||
|
<BackgroundRemovalHandler />
|
||||||
<PWARegistration />
|
<PWARegistration />
|
||||||
<UploadQueue />
|
<UploadQueue />
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { MetadataRoute } from 'next'
|
|||||||
|
|
||||||
export default function manifest(): MetadataRoute.Manifest {
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
return {
|
return {
|
||||||
name: 'Whisky Vault',
|
name: 'Dramlog.eu',
|
||||||
short_name: 'WhiskyVault',
|
short_name: 'Dramlog',
|
||||||
description: 'Dein persönlicher Whisky-Begleiter zum Scannen und Verkosten.',
|
description: 'Dein persönlicher Whisky-Begleiter zum Scannen und Verkosten.',
|
||||||
start_url: '/',
|
start_url: '/',
|
||||||
display: 'standalone',
|
display: 'standalone',
|
||||||
|
|||||||
178
src/app/page.tsx
178
src/app/page.tsx
@@ -1,30 +1,29 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
import BottleGrid from "@/components/BottleGrid";
|
import BottleGrid from "@/components/BottleGrid";
|
||||||
import AuthForm from "@/components/AuthForm";
|
import AuthForm from "@/components/AuthForm";
|
||||||
import BuddyList from "@/components/BuddyList";
|
|
||||||
import SessionList from "@/components/SessionList";
|
|
||||||
import StatsDashboard from "@/components/StatsDashboard";
|
|
||||||
import DramOfTheDay from "@/components/DramOfTheDay";
|
|
||||||
import LanguageSwitcher from "@/components/LanguageSwitcher";
|
|
||||||
import OfflineIndicator from "@/components/OfflineIndicator";
|
import OfflineIndicator from "@/components/OfflineIndicator";
|
||||||
import { useI18n } from "@/i18n/I18nContext";
|
import { useI18n } from "@/i18n/I18nContext";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import { useSession } from "@/context/SessionContext";
|
import { useSession } from "@/context/SessionContext";
|
||||||
import TastingHub from "@/components/TastingHub";
|
import TastingHub from "@/components/TastingHub";
|
||||||
import { Sparkles, X, Loader2 } from "lucide-react";
|
import { Sparkles, Loader2, Search, SlidersHorizontal } from "lucide-react";
|
||||||
import { BottomNavigation } from '@/components/BottomNavigation';
|
import { BottomNavigation } from '@/components/BottomNavigation';
|
||||||
import ScanAndTasteFlow from '@/components/ScanAndTasteFlow';
|
import ScanAndTasteFlow from '@/components/ScanAndTasteFlow';
|
||||||
import UserStatusBadge from '@/components/UserStatusBadge';
|
import UserStatusBadge from '@/components/UserStatusBadge';
|
||||||
import { getActiveSplits } from '@/services/split-actions';
|
import { getActiveSplits } from '@/services/split-actions';
|
||||||
import SplitCard from '@/components/SplitCard';
|
import SplitCard from '@/components/SplitCard';
|
||||||
|
import HeroBanner from '@/components/HeroBanner';
|
||||||
|
import QuickActionsGrid from '@/components/QuickActionsGrid';
|
||||||
|
import { checkIsAdmin } from '@/services/track-api-usage';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const [bottles, setBottles] = useState<any[]>([]);
|
const [bottles, setBottles] = useState<any[]>([]);
|
||||||
const { user, isLoading: isAuthLoading } = useAuth();
|
const { user, isLoading: isAuthLoading } = useAuth();
|
||||||
const [isInternalLoading, setIsInternalLoading] = useState(false);
|
const [isInternalLoading, setIsInternalLoading] = useState(false);
|
||||||
@@ -36,6 +35,7 @@ export default function Home() {
|
|||||||
const [capturedFile, setCapturedFile] = useState<File | null>(null);
|
const [capturedFile, setCapturedFile] = useState<File | null>(null);
|
||||||
const [hasMounted, setHasMounted] = useState(false);
|
const [hasMounted, setHasMounted] = useState(false);
|
||||||
const [publicSplits, setPublicSplits] = useState<any[]>([]);
|
const [publicSplits, setPublicSplits] = useState<any[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHasMounted(true);
|
setHasMounted(true);
|
||||||
@@ -47,7 +47,6 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only fetch if auth is ready and user exists
|
|
||||||
if (!isAuthLoading && user) {
|
if (!isAuthLoading && user) {
|
||||||
fetchCollection();
|
fetchCollection();
|
||||||
} else if (!isAuthLoading && !user) {
|
} else if (!isAuthLoading && !user) {
|
||||||
@@ -56,14 +55,12 @@ export default function Home() {
|
|||||||
}, [user, isAuthLoading]);
|
}, [user, isAuthLoading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Fetch public splits if guest
|
|
||||||
getActiveSplits().then(res => {
|
getActiveSplits().then(res => {
|
||||||
if (res.success && res.splits) {
|
if (res.success && res.splits) {
|
||||||
setPublicSplits(res.splits);
|
setPublicSplits(res.splits);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for collection updates (e.g., after offline sync completes)
|
|
||||||
const handleCollectionUpdated = () => {
|
const handleCollectionUpdated = () => {
|
||||||
console.log('[Home] Collection update event received, refreshing...');
|
console.log('[Home] Collection update event received, refreshing...');
|
||||||
fetchCollection();
|
fetchCollection();
|
||||||
@@ -78,7 +75,6 @@ export default function Home() {
|
|||||||
const fetchCollection = async () => {
|
const fetchCollection = async () => {
|
||||||
setIsInternalLoading(true);
|
setIsInternalLoading(true);
|
||||||
try {
|
try {
|
||||||
// Fetch bottles with their latest tasting date
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('bottles')
|
.from('bottles')
|
||||||
.select(`
|
.select(`
|
||||||
@@ -90,13 +86,10 @@ export default function Home() {
|
|||||||
`)
|
`)
|
||||||
.order('created_at', { ascending: false });
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
if (error) {
|
if (error) throw error;
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Fetched ${data?.length || 0} bottles from Supabase`);
|
console.log(`Fetched ${data?.length || 0} bottles from Supabase`);
|
||||||
|
|
||||||
// Process data to get the absolute latest tasting date for each bottle
|
|
||||||
const processedBottles = (data || []).map(bottle => {
|
const processedBottles = (data || []).map(bottle => {
|
||||||
const lastTasted = bottle.tastings && bottle.tastings.length > 0
|
const lastTasted = bottle.tastings && bottle.tastings.length > 0
|
||||||
? bottle.tastings.reduce((latest: string, current: any) =>
|
? bottle.tastings.reduce((latest: string, current: any) =>
|
||||||
@@ -105,41 +98,18 @@ export default function Home() {
|
|||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return {
|
return { ...bottle, last_tasted: lastTasted };
|
||||||
...bottle,
|
|
||||||
last_tasted: lastTasted
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setBottles(processedBottles);
|
setBottles(processedBottles);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// Enhanced logging for empty-looking error objects
|
console.warn('[Home] Fetch collection error:', err?.message);
|
||||||
console.warn('[Home] Fetch collection error caught:', {
|
|
||||||
name: err?.name,
|
|
||||||
message: err?.message,
|
|
||||||
keys: err ? Object.keys(err) : [],
|
|
||||||
allProps: err ? Object.getOwnPropertyNames(err) : [],
|
|
||||||
stack: err?.stack,
|
|
||||||
online: navigator.onLine
|
|
||||||
});
|
|
||||||
|
|
||||||
// Silently skip if offline or common network failure
|
|
||||||
const isNetworkError = !navigator.onLine ||
|
const isNetworkError = !navigator.onLine ||
|
||||||
err?.name === 'TypeError' ||
|
err?.name === 'TypeError' ||
|
||||||
err?.message?.includes('Failed to fetch') ||
|
err?.message?.includes('Failed to fetch');
|
||||||
err?.message?.includes('NetworkError') ||
|
|
||||||
err?.message?.includes('ERR_INTERNET_DISCONNECTED') ||
|
|
||||||
(err && typeof err === 'object' && !err.message && Object.keys(err).length === 0);
|
|
||||||
|
|
||||||
if (isNetworkError) {
|
if (!isNetworkError) {
|
||||||
console.log('[fetchCollection] Skipping due to offline mode or network error');
|
setFetchError(err?.message || 'Unknown error');
|
||||||
setFetchError(null);
|
|
||||||
} else {
|
|
||||||
console.error('Detailed fetch error:', err);
|
|
||||||
// Safe stringification for Error objects
|
|
||||||
const errorMessage = err?.message ||
|
|
||||||
(err && typeof err === 'object' ? JSON.stringify(err, Object.getOwnPropertyNames(err)) : String(err));
|
|
||||||
setFetchError(errorMessage);
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsInternalLoading(false);
|
setIsInternalLoading(false);
|
||||||
@@ -150,6 +120,17 @@ export default function Home() {
|
|||||||
await supabase.auth.signOut();
|
await supabase.auth.signOut();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Filter bottles by search query
|
||||||
|
const filteredBottles = bottles.filter(bottle => {
|
||||||
|
if (!searchQuery.trim()) return true;
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return (
|
||||||
|
bottle.name?.toLowerCase().includes(query) ||
|
||||||
|
bottle.distillery?.toLowerCase().includes(query) ||
|
||||||
|
bottle.category?.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
if (!hasMounted) {
|
if (!hasMounted) {
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen flex-col items-center justify-center bg-zinc-950">
|
<main className="flex min-h-screen flex-col items-center justify-center bg-zinc-950">
|
||||||
@@ -158,6 +139,7 @@ export default function Home() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Guest / Login View
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen flex-col items-center justify-center p-6 bg-zinc-950">
|
<main className="flex min-h-screen flex-col items-center justify-center p-6 bg-zinc-950">
|
||||||
@@ -168,13 +150,10 @@ export default function Home() {
|
|||||||
<p className="text-zinc-500 max-w-sm mx-auto font-bold tracking-wide">
|
<p className="text-zinc-500 max-w-sm mx-auto font-bold tracking-wide">
|
||||||
{t('home.tagline')}
|
{t('home.tagline')}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-8">
|
|
||||||
<LanguageSwitcher />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<AuthForm />
|
<AuthForm />
|
||||||
|
|
||||||
{!user && publicSplits.length > 0 && (
|
{publicSplits.length > 0 && (
|
||||||
<div className="mt-16 w-full max-w-lg space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-1000 delay-300">
|
<div className="mt-16 w-full max-w-lg space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-1000 delay-300">
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<h2 className="text-[10px] font-black uppercase tracking-[0.4em] text-orange-600/60">
|
<h2 className="text-[10px] font-black uppercase tracking-[0.4em] text-orange-600/60">
|
||||||
@@ -199,16 +178,30 @@ export default function Home() {
|
|||||||
|
|
||||||
const isLoading = isAuthLoading || isInternalLoading;
|
const isLoading = isAuthLoading || isInternalLoading;
|
||||||
|
|
||||||
|
// Authenticated Home View - New Layout
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen flex-col items-center gap-6 md:gap-12 p-4 md:p-24 bg-[var(--background)] pb-32">
|
<div className="flex flex-col min-h-screen bg-(--background) relative">
|
||||||
<div className="z-10 max-w-5xl w-full flex flex-col items-center gap-12">
|
{/* Scrollable Content Area */}
|
||||||
<header className="w-full flex flex-col sm:flex-row justify-between items-center gap-4 sm:gap-0">
|
<div className="flex-1 overflow-y-auto pb-24">
|
||||||
<div className="flex flex-col items-center sm:items-start group">
|
{/* 1. Header */}
|
||||||
<h1 className="text-4xl font-bold text-zinc-50 tracking-tighter">
|
<header className="px-4 pt-4 pb-2 space-y-2">
|
||||||
|
{/* Row 1: Logo + Logout */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-zinc-50 tracking-tighter">
|
||||||
DRAM<span className="text-orange-600">LOG</span>
|
DRAM<span className="text-orange-600">LOG</span>
|
||||||
</h1>
|
</h1>
|
||||||
{activeSession && (
|
<button
|
||||||
<div className="flex items-center gap-2 mt-1 animate-in fade-in slide-in-from-left-2 duration-700">
|
onClick={handleLogout}
|
||||||
|
className="text-[10px] font-bold uppercase tracking-widest text-zinc-500 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
{t('home.logout')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: Session info + Status */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{activeSession ? (
|
||||||
|
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-left-2 duration-700">
|
||||||
<div className="relative flex h-2 w-2">
|
<div className="relative flex h-2 w-2">
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-orange-600 opacity-75"></span>
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-orange-600 opacity-75"></span>
|
||||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-orange-600"></span>
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-orange-600"></span>
|
||||||
@@ -218,42 +211,54 @@ export default function Home() {
|
|||||||
Live: {activeSession.name}
|
Live: {activeSession.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
)}
|
)}
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex flex-wrap items-center justify-center sm:justify-end gap-3 md:gap-4">
|
|
||||||
<UserStatusBadge />
|
<UserStatusBadge />
|
||||||
<OfflineIndicator />
|
<OfflineIndicator />
|
||||||
<LanguageSwitcher />
|
</div>
|
||||||
<DramOfTheDay bottles={bottles} />
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
className="text-[10px] font-bold uppercase tracking-widest text-zinc-500 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
{t('home.logout')}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="w-full">
|
{/* 2. Hero Banner (optional) */}
|
||||||
<StatsDashboard bottles={bottles} />
|
<div className="px-4 mt-2 mb-4">
|
||||||
|
<HeroBanner />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 w-full max-w-5xl">
|
|
||||||
<div className="flex flex-col gap-8">
|
{/* 3. Quick Actions Grid */}
|
||||||
<SessionList />
|
<div className="px-4 mb-4">
|
||||||
|
<QuickActionsGrid />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<BuddyList />
|
{/* 4. Sticky Search Bar */}
|
||||||
|
<div className="sticky top-0 z-20 px-4 py-3 bg-zinc-950/95 backdrop-blur-md border-b border-zinc-900">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t('home.searchPlaceholder') || 'Search collection...'}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-9 pr-4 py-2.5 bg-zinc-900 border border-zinc-800 rounded-xl text-sm text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600/50 focus:ring-1 focus:ring-orange-600/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button className="p-2.5 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-500 hover:text-white hover:border-zinc-700 transition-colors">
|
||||||
|
<SlidersHorizontal size={18} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full mt-4" id="collection">
|
{/* 5. Collection */}
|
||||||
<div className="flex items-end justify-between mb-8">
|
<div className="px-4 mt-4">
|
||||||
<h2 className="text-3xl font-bold text-zinc-50 uppercase tracking-tight">
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-bold text-zinc-50">
|
||||||
{t('home.collection')}
|
{t('home.collection')}
|
||||||
</h2>
|
</h2>
|
||||||
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest pb-1">
|
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">
|
||||||
{bottles.length} {t('home.bottleCount')}
|
{filteredBottles.length} {t('home.bottleCount')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -262,20 +267,23 @@ export default function Home() {
|
|||||||
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-orange-600"></div>
|
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-orange-600"></div>
|
||||||
</div>
|
</div>
|
||||||
) : fetchError ? (
|
) : fetchError ? (
|
||||||
<div className="p-12 bg-zinc-900 border border-zinc-800 rounded-3xl text-center">
|
<div className="p-8 bg-zinc-900 border border-zinc-800 rounded-2xl text-center">
|
||||||
<p className="text-zinc-50 font-bold text-xl mb-2">{t('common.error')}</p>
|
<p className="text-zinc-50 font-bold mb-2">{t('common.error')}</p>
|
||||||
<p className="text-zinc-500 text-xs italic mb-8 mx-auto max-w-xs">{fetchError}</p>
|
<p className="text-zinc-500 text-xs mb-6">{fetchError}</p>
|
||||||
<button
|
<button
|
||||||
onClick={fetchCollection}
|
onClick={fetchCollection}
|
||||||
className="px-10 py-4 bg-orange-600 hover:bg-orange-700 text-white rounded-2xl text-[10px] font-bold uppercase tracking-widest transition-all shadow-lg shadow-orange-950/20"
|
className="px-6 py-3 bg-orange-600 hover:bg-orange-700 text-white rounded-xl text-xs font-bold uppercase tracking-widest transition-all"
|
||||||
>
|
>
|
||||||
{t('home.reTry')}
|
{t('home.reTry')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : filteredBottles.length > 0 ? (
|
||||||
bottles.length > 0 && <BottleGrid bottles={bottles} />
|
<BottleGrid bottles={filteredBottles} />
|
||||||
)}
|
) : bottles.length > 0 ? (
|
||||||
|
<div className="text-center py-12 text-zinc-500">
|
||||||
|
<p className="text-sm">No bottles match your search</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
@@ -288,7 +296,9 @@ export default function Home() {
|
|||||||
<a href="/settings" className="hover:text-orange-500 transition-colors">{t('home.settings')}</a>
|
<a href="/settings" className="hover:text-orange-500 transition-colors">{t('home.settings')}</a>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Navigation with FAB */}
|
||||||
<BottomNavigation
|
<BottomNavigation
|
||||||
onHome={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
onHome={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||||
onShelf={() => document.getElementById('collection')?.scrollIntoView({ behavior: 'smooth' })}
|
onShelf={() => document.getElementById('collection')?.scrollIntoView({ behavior: 'smooth' })}
|
||||||
@@ -308,6 +318,6 @@ export default function Home() {
|
|||||||
imageFile={capturedFile}
|
imageFile={capturedFile}
|
||||||
onBottleSaved={() => fetchCollection()}
|
onBottleSaved={() => fetchCollection()}
|
||||||
/>
|
/>
|
||||||
</main>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import SessionABVCurve from '@/components/SessionABVCurve';
|
|||||||
import OfflineIndicator from '@/components/OfflineIndicator';
|
import OfflineIndicator from '@/components/OfflineIndicator';
|
||||||
import BulkScanSheet from '@/components/BulkScanSheet';
|
import BulkScanSheet from '@/components/BulkScanSheet';
|
||||||
import BottleSkeletonCard from '@/components/BottleSkeletonCard';
|
import BottleSkeletonCard from '@/components/BottleSkeletonCard';
|
||||||
|
import ScanAndTasteFlow from '@/components/ScanAndTasteFlow';
|
||||||
|
|
||||||
interface Buddy {
|
interface Buddy {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -34,12 +35,20 @@ interface Session {
|
|||||||
name: string;
|
name: string;
|
||||||
scheduled_at: string;
|
scheduled_at: string;
|
||||||
ended_at?: string;
|
ended_at?: string;
|
||||||
|
is_blind: boolean;
|
||||||
|
is_revealed: boolean;
|
||||||
|
user_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SessionTasting {
|
interface SessionTasting {
|
||||||
id: string;
|
id: string;
|
||||||
rating: number;
|
rating: number;
|
||||||
tasted_at: string;
|
tasted_at: string;
|
||||||
|
blind_label?: string;
|
||||||
|
guess_abv?: number;
|
||||||
|
guess_age?: number;
|
||||||
|
guess_region?: string;
|
||||||
|
guess_points?: number;
|
||||||
bottles: {
|
bottles: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -57,21 +66,36 @@ interface SessionTasting {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SessionDetailPage() {
|
export default function SessionDetailPage() {
|
||||||
const { t } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { activeSession, setActiveSession } = useSession();
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
|
|
||||||
const [session, setSession] = useState<Session | null>(null);
|
const [session, setSession] = useState<Session | null>(null);
|
||||||
const [participants, setParticipants] = useState<Participant[]>([]);
|
|
||||||
const [tastings, setTastings] = useState<SessionTasting[]>([]);
|
const [tastings, setTastings] = useState<SessionTasting[]>([]);
|
||||||
|
const [participants, setParticipants] = useState<Participant[]>([]);
|
||||||
const [allBuddies, setAllBuddies] = useState<Buddy[]>([]);
|
const [allBuddies, setAllBuddies] = useState<Buddy[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const { user, isLoading: isAuthLoading } = useAuth();
|
const { user, isLoading: isAuthLoading } = useAuth();
|
||||||
const { activeSession, setActiveSession } = useSession();
|
|
||||||
const [isAddingParticipant, setIsAddingParticipant] = useState(false);
|
const [isAddingParticipant, setIsAddingParticipant] = useState(false);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
const [isClosing, setIsClosing] = useState(false);
|
const [isClosing, setIsClosing] = useState(false);
|
||||||
const [isBulkScanOpen, setIsBulkScanOpen] = useState(false);
|
const [isBulkScanOpen, setIsBulkScanOpen] = useState(false);
|
||||||
|
const [isUpdatingBlind, setIsUpdatingBlind] = useState(false);
|
||||||
|
|
||||||
|
// New: Direct Scan Flow
|
||||||
|
const [isScanFlowOpen, setIsScanFlowOpen] = useState(false);
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
setSelectedFile(file);
|
||||||
|
setIsScanFlowOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthLoading && user) {
|
if (!isAuthLoading && user) {
|
||||||
@@ -131,6 +155,11 @@ export default function SessionDetailPage() {
|
|||||||
id,
|
id,
|
||||||
rating,
|
rating,
|
||||||
tasted_at,
|
tasted_at,
|
||||||
|
blind_label,
|
||||||
|
guess_abv,
|
||||||
|
guess_age,
|
||||||
|
guess_region,
|
||||||
|
guess_points,
|
||||||
bottles(id, name, distillery, image_url, abv, category, processing_status),
|
bottles(id, name, distillery, image_url, abv, category, processing_status),
|
||||||
tasting_tags(tags(name))
|
tasting_tags(tags(name))
|
||||||
`)
|
`)
|
||||||
@@ -183,21 +212,67 @@ export default function SessionDetailPage() {
|
|||||||
|
|
||||||
const handleCloseSession = async () => {
|
const handleCloseSession = async () => {
|
||||||
if (!confirm('Möchtest du diese Session wirklich abschließen?')) return;
|
if (!confirm('Möchtest du diese Session wirklich abschließen?')) return;
|
||||||
|
|
||||||
setIsClosing(true);
|
setIsClosing(true);
|
||||||
const result = await closeSession(id as string);
|
const { success } = await closeSession(id as string);
|
||||||
|
if (success) {
|
||||||
if (result.success) {
|
|
||||||
if (activeSession?.id === id) {
|
if (activeSession?.id === id) {
|
||||||
setActiveSession(null);
|
setActiveSession(null);
|
||||||
}
|
}
|
||||||
fetchSessionData();
|
fetchSessionData();
|
||||||
} else {
|
|
||||||
alert(result.error);
|
|
||||||
}
|
}
|
||||||
setIsClosing(false);
|
setIsClosing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleBlindMode = async () => {
|
||||||
|
if (!session) return;
|
||||||
|
setIsUpdatingBlind(true);
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('tasting_sessions')
|
||||||
|
.update({ is_blind: !session.is_blind })
|
||||||
|
.eq('id', id);
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
fetchSessionData();
|
||||||
|
}
|
||||||
|
setIsUpdatingBlind(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevealBlindMode = async () => {
|
||||||
|
if (!session) return;
|
||||||
|
if (!confirm('Möchtest du alle Flaschen aufdecken?')) return;
|
||||||
|
|
||||||
|
setIsUpdatingBlind(true);
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('tasting_sessions')
|
||||||
|
.update({ is_revealed: true })
|
||||||
|
.eq('id', id);
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
fetchSessionData();
|
||||||
|
}
|
||||||
|
setIsUpdatingBlind(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateGuessPoints = (tasting: SessionTasting) => {
|
||||||
|
let points = 0;
|
||||||
|
|
||||||
|
// ABV Scoring (100 base - 10 per 1% dev)
|
||||||
|
if (tasting.guess_abv && tasting.bottles.abv) {
|
||||||
|
const abvDev = Math.abs(tasting.guess_abv - tasting.bottles.abv);
|
||||||
|
points += Math.max(0, 100 - (abvDev * 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Age Scoring (100 base - 5 per year dev)
|
||||||
|
// Note: bottles table has 'age' as integer
|
||||||
|
const bottleAge = (tasting.bottles as any).age;
|
||||||
|
if (tasting.guess_age && bottleAge) {
|
||||||
|
const ageDev = Math.abs(tasting.guess_age - bottleAge);
|
||||||
|
points += Math.max(0, 100 - (ageDev * 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.round(points);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeleteSession = async () => {
|
const handleDeleteSession = async () => {
|
||||||
if (!confirm('Möchtest du diese Session wirklich löschen? Alle Verknüpfungen gehen verloren.')) return;
|
if (!confirm('Möchtest du diese Session wirklich löschen? Alle Verknüpfungen gehen verloren.')) return;
|
||||||
|
|
||||||
@@ -233,95 +308,129 @@ export default function SessionDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-zinc-950 p-4 md:p-12 lg:p-24">
|
<main className="min-h-screen bg-(--background) p-4 md:p-12 lg:p-24 pb-32">
|
||||||
<div className="max-w-4xl mx-auto space-y-8">
|
<div className="max-w-6xl mx-auto space-y-12">
|
||||||
{/* Back Button */}
|
{/* Back Link & Info */}
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="inline-flex items-center gap-2 text-zinc-500 hover:text-orange-600 transition-colors font-bold uppercase text-[10px] tracking-[0.2em]"
|
className="group inline-flex items-center gap-3 text-zinc-500 hover:text-orange-600 transition-all font-black uppercase text-[10px] tracking-[0.3em]"
|
||||||
>
|
>
|
||||||
|
<div className="p-2 rounded-full border border-zinc-800 group-hover:border-orange-500/50 transition-colors">
|
||||||
<ChevronLeft size={16} />
|
<ChevronLeft size={16} />
|
||||||
|
</div>
|
||||||
Alle Sessions
|
Alle Sessions
|
||||||
</Link>
|
</Link>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
<OfflineIndicator />
|
<OfflineIndicator />
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
className="hidden"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hero */}
|
{/* Immersive Header */}
|
||||||
<header className="bg-zinc-900 rounded-3xl p-8 border border-zinc-800 shadow-xl relative overflow-hidden group">
|
<header className="relative bg-zinc-900 border border-white/5 rounded-[48px] p-8 md:p-12 shadow-[0_20px_80px_rgba(0,0,0,0.5)] overflow-hidden group">
|
||||||
{/* Visual Eyecatcher: Background Glow */}
|
{/* Background Visuals */}
|
||||||
|
<div className="absolute inset-0 bg-linear-to-br from-zinc-900 via-zinc-900 to-black z-0" />
|
||||||
{tastings.length > 0 && tastings[0].bottles.image_url && (
|
{tastings.length > 0 && tastings[0].bottles.image_url && (
|
||||||
<div className="absolute top-0 right-0 w-1/2 h-full opacity-20 dark:opacity-30 pointer-events-none">
|
<div className="absolute top-0 right-0 w-2/3 h-full opacity-30 z-0">
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-cover bg-center scale-150 blur-3xl transition-all duration-1000 group-hover:scale-125"
|
className="absolute inset-0 bg-cover bg-center scale-150 blur-[100px]"
|
||||||
style={{ backgroundImage: `url(${tastings[0].bottles.image_url})` }}
|
style={{ backgroundImage: `url(${tastings[0].bottles.image_url})` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="absolute top-0 right-0 p-8 opacity-5 text-zinc-400">
|
{/* Decorative Rings */}
|
||||||
<GlassWater size={120} />
|
<div className="absolute -top-24 -right-24 w-96 h-96 border border-orange-500/10 rounded-full z-0" />
|
||||||
</div>
|
<div className="absolute -top-12 -right-12 w-96 h-96 border border-orange-500/5 rounded-full z-0" />
|
||||||
|
|
||||||
<div className="relative z-10 flex flex-col md:flex-row justify-between items-start md:items-center gap-6">
|
<div className="relative z-10 flex flex-col lg:flex-row justify-between items-start lg:items-end gap-8">
|
||||||
<div className="flex-1 flex flex-col md:flex-row gap-6 items-start md:items-center">
|
<div className="space-y-6 flex-1">
|
||||||
{/* Visual Eyecatcher: Bottle Preview */}
|
<div className="flex items-center gap-3">
|
||||||
{tastings.length > 0 && tastings[0].bottles.image_url && (
|
<div className="px-3 py-1 bg-orange-600/10 border border-orange-500/20 rounded-full flex items-center gap-2">
|
||||||
<div className="shrink-0 relative">
|
<Sparkles size={12} className="text-orange-500 animate-pulse" />
|
||||||
<div className="w-20 h-20 md:w-24 md:h-24 rounded-2xl bg-zinc-800 border-2 border-orange-500/20 shadow-2xl overflow-hidden relative group-hover:rotate-3 transition-transform duration-500">
|
<span className="text-[10px] font-black text-orange-500 uppercase tracking-[0.2em]">Tasting Session</span>
|
||||||
<img
|
|
||||||
src={tastings[0].bottles.image_url}
|
|
||||||
alt={tastings[0].bottles.name}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
||||||
</div>
|
|
||||||
<div className="absolute -bottom-2 -right-2 bg-orange-600 text-white text-[10px] font-black px-2 py-1 rounded-lg shadow-lg rotate-12">
|
|
||||||
LATEST
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex items-center gap-2 text-orange-600 font-black uppercase text-[10px] tracking-widest">
|
|
||||||
<Sparkles size={14} />
|
|
||||||
Tasting Session
|
|
||||||
</div>
|
</div>
|
||||||
{session.ended_at && (
|
{session.ended_at && (
|
||||||
<span className="bg-zinc-100 dark:bg-zinc-800 text-zinc-500 text-[8px] font-black px-2 py-0.5 rounded-md uppercase tracking-widest border border-zinc-200 dark:border-zinc-700">Abgeschlossen</span>
|
<span className="px-3 py-1 bg-zinc-800/50 border border-zinc-700/50 rounded-full text-[10px] font-black text-zinc-500 uppercase tracking-[0.2em]">Archiviert</span>
|
||||||
|
)}
|
||||||
|
{session.is_blind && (
|
||||||
|
<span className="px-3 py-1 bg-purple-600/10 border border-purple-500/20 rounded-full flex items-center gap-2">
|
||||||
|
<div className="w-1.5 h-1.5 bg-purple-500 rounded-full animate-pulse" />
|
||||||
|
<span className="text-[10px] font-black text-purple-500 uppercase tracking-[0.2em]">Blind Modus</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{session.is_blind && session.is_revealed && (
|
||||||
|
<span className="px-3 py-1 bg-green-600/10 border border-green-500/20 rounded-full flex items-center gap-2">
|
||||||
|
<Sparkles size={10} className="text-green-500" />
|
||||||
|
<span className="text-[10px] font-black text-green-500 uppercase tracking-[0.2em]">Revealed</span>
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-4xl md:text-5xl font-black text-zinc-50 tracking-tighter">
|
|
||||||
|
<h1 className="text-5xl md:text-7xl font-black text-white tracking-tighter leading-[0.9]">
|
||||||
{session.name}
|
{session.name}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex flex-wrap items-center gap-3 sm:gap-6 text-zinc-500 font-bold text-sm">
|
|
||||||
<span className="flex items-center gap-1.5 bg-zinc-800/40 px-3 py-1.5 rounded-2xl border border-zinc-800 shadow-sm">
|
<div className="flex flex-wrap items-center gap-4 text-zinc-400">
|
||||||
|
<div className="flex items-center gap-2 bg-black/30 backdrop-blur-md px-4 py-2 rounded-2xl border border-white/5 shadow-inner">
|
||||||
<Calendar size={16} className="text-orange-600" />
|
<Calendar size={16} className="text-orange-600" />
|
||||||
{new Date(session.scheduled_at).toLocaleDateString('de-DE')}
|
<span className="text-xs font-black uppercase tracking-widest">{new Date(session.scheduled_at).toLocaleDateString('de-DE')}</span>
|
||||||
</span>
|
</div>
|
||||||
{participants.length > 0 && (
|
{participants.length > 0 && (
|
||||||
<div className="flex items-center gap-2 bg-zinc-800/40 px-3 py-1.5 rounded-2xl border border-zinc-800 shadow-sm">
|
<div className="flex items-center gap-3 bg-black/30 backdrop-blur-md px-4 py-2 rounded-2xl border border-white/5">
|
||||||
<Users size={16} className="text-orange-600" />
|
<Users size={16} className="text-orange-600" />
|
||||||
<AvatarStack names={participants.map(p => p.buddies.name)} limit={5} />
|
<AvatarStack names={participants.map(p => p.buddies.name)} limit={5} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{tastings.length > 0 && (
|
<div className="flex items-center gap-2 bg-black/30 backdrop-blur-md px-4 py-2 rounded-2xl border border-white/5">
|
||||||
<span className="flex items-center gap-1.5 bg-zinc-800/40 px-3 py-1.5 rounded-2xl border border-zinc-800 shadow-sm transition-all animate-in fade-in slide-in-from-left-2">
|
|
||||||
<GlassWater size={16} className="text-orange-600" />
|
<GlassWater size={16} className="text-orange-600" />
|
||||||
{tastings.length} {tastings.length === 1 ? 'Whisky' : 'Whiskys'}
|
<span className="text-xs font-black tracking-widest">{tastings.length} {tastings.length === 1 ? 'DRAM' : 'DRAMS'}</span>
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap gap-3 z-20">
|
||||||
|
{/* Host Controls for Blind Mode */}
|
||||||
|
{user?.id === session.user_id && !session.ended_at && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleToggleBlindMode}
|
||||||
|
disabled={isUpdatingBlind}
|
||||||
|
className={`px-6 py-4 rounded-2xl text-[10px] font-black uppercase tracking-[0.2em] flex items-center gap-3 transition-all border ${session.is_blind
|
||||||
|
? 'bg-purple-600/20 border-purple-500/50 text-purple-400'
|
||||||
|
: 'bg-zinc-800/50 border-zinc-700/50 text-zinc-400 hover:border-zinc-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isUpdatingBlind ? <Loader2 size={16} className="animate-spin" /> : <Play size={14} className={session.is_blind ? "fill-purple-400" : ""} />}
|
||||||
|
Blind Mode
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{session.is_blind && !session.is_revealed && (
|
||||||
|
<button
|
||||||
|
onClick={handleRevealBlindMode}
|
||||||
|
disabled={isUpdatingBlind}
|
||||||
|
className="px-6 py-4 bg-green-600 hover:bg-green-500 text-white rounded-2xl text-[10px] font-black uppercase tracking-[0.2em] flex items-center gap-3 transition-all shadow-lg shadow-green-950/20"
|
||||||
|
>
|
||||||
|
<Sparkles size={16} />
|
||||||
|
Reveal
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{!session.ended_at && (
|
{!session.ended_at && (
|
||||||
activeSession?.id !== session.id ? (
|
activeSession?.id !== session.id ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveSession({ id: session.id, name: session.name })}
|
onClick={() => setActiveSession({ id: session.id, name: session.name })}
|
||||||
className="px-6 py-3 bg-orange-600 hover:bg-orange-700 text-white rounded-2xl text-sm font-black uppercase tracking-widest flex items-center gap-2 transition-all shadow-xl shadow-orange-950/20"
|
className="px-8 py-4 bg-orange-600 hover:bg-orange-500 text-white rounded-2xl text-[10px] font-black uppercase tracking-[0.2em] flex items-center gap-3 transition-all shadow-[0_10px_40px_rgba(234,88,12,0.3)] hover:-translate-y-1 active:translate-y-0"
|
||||||
>
|
>
|
||||||
<Play size={18} fill="currentColor" />
|
<Play size={18} fill="currentColor" />
|
||||||
Starten
|
Starten
|
||||||
@@ -330,74 +439,87 @@ export default function SessionDetailPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleCloseSession}
|
onClick={handleCloseSession}
|
||||||
disabled={isClosing}
|
disabled={isClosing}
|
||||||
className="px-6 py-3 bg-zinc-100 text-zinc-900 rounded-2xl text-sm font-black uppercase tracking-widest flex items-center gap-2 border border-zinc-800 hover:bg-red-600 hover:text-white transition-all group"
|
className="px-8 py-4 bg-zinc-800/50 backdrop-blur-xl border border-zinc-700/50 text-zinc-100 hover:bg-red-600 hover:border-red-500 rounded-2xl text-[10px] font-black uppercase tracking-[0.2em] flex items-center gap-3 transition-all hover:shadow-[0_10px_40px_rgba(220,38,38,0.2)]"
|
||||||
>
|
>
|
||||||
{isClosing ? <Loader2 size={18} className="animate-spin" /> : <Square size={18} className="text-red-500 group-hover:text-white transition-colors" fill="currentColor" />}
|
{isClosing ? <Loader2 size={18} className="animate-spin" /> : <Square size={16} className="text-red-500 group-hover:text-white" fill="currentColor" />}
|
||||||
Beenden
|
Session Beenden
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleDeleteSession}
|
onClick={handleDeleteSession}
|
||||||
disabled={isDeleting}
|
disabled={isDeleting}
|
||||||
title="Session löschen"
|
className="p-4 bg-zinc-950 border border-white/5 text-zinc-600 hover:text-red-500 rounded-2xl transition-all"
|
||||||
className="p-3 bg-red-900/10 text-red-400 rounded-2xl hover:bg-red-600 hover:text-white transition-all border border-red-900/20 disabled:opacity-50"
|
|
||||||
>
|
>
|
||||||
{isDeleting ? <Loader2 size={20} className="animate-spin" /> : <Trash2 size={20} />}
|
{isDeleting ? <Loader2 size={18} className="animate-spin" /> : <Trash2 size={18} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
{/* Blind Mode Reveal Leaderboard */}
|
||||||
{/* Sidebar: Participants */}
|
{session.is_blind && session.is_revealed && (
|
||||||
<aside className="md:col-span-1 space-y-6">
|
<section className="bg-purple-900/10 rounded-[40px] p-8 md:p-12 border border-purple-500/30 shadow-2xl relative overflow-hidden">
|
||||||
<div className="bg-zinc-900 rounded-3xl p-6 border border-zinc-800 shadow-lg">
|
<div className="absolute top-0 right-0 p-12 opacity-5">
|
||||||
<h3 className="text-sm font-black uppercase tracking-widest text-zinc-500 mb-6 flex items-center gap-2">
|
<Sparkles size={120} className="text-purple-500" />
|
||||||
<Users size={16} className="text-orange-600" />
|
|
||||||
Teilnehmer
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="space-y-3 mb-6">
|
|
||||||
{participants.length === 0 ? (
|
|
||||||
<p className="text-xs text-zinc-500 italic">Noch keine Teilnehmer...</p>
|
|
||||||
) : (
|
|
||||||
participants.map((p) => (
|
|
||||||
<div key={p.buddy_id} className="flex items-center justify-between group">
|
|
||||||
<span className="text-sm font-bold text-zinc-300">{p.buddies.name}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => handleRemoveParticipant(p.buddy_id)}
|
|
||||||
className="text-zinc-600 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all"
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
))
|
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-6 mb-12 relative">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-[10px] font-black uppercase tracking-[0.4em] text-purple-400">Leaderboard</h3>
|
||||||
|
<p className="text-3xl font-black text-white tracking-tight leading-none italic">Die Goldene Nase</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-2 bg-purple-600/20 border border-purple-500/30 rounded-2xl text-[10px] font-black text-purple-400 uppercase tracking-widest">
|
||||||
|
Mystery Revealed
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 relative">
|
||||||
|
{tastings.map((t, idx) => {
|
||||||
|
const score = calculateGuessPoints(t);
|
||||||
|
return (
|
||||||
|
<div key={t.id} className="bg-black/40 border border-white/5 rounded-[32px] p-6 group hover:border-purple-500/30 transition-all">
|
||||||
|
<div className="flex justify-between items-start mb-6">
|
||||||
|
<div className="w-10 h-10 bg-purple-600/20 border border-purple-500/20 rounded-full flex items-center justify-center text-xs font-black text-purple-400">
|
||||||
|
{String.fromCharCode(65 + idx)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-2xl font-black text-white">{score}</div>
|
||||||
|
<div className="text-[9px] font-black text-purple-400 uppercase tracking-tighter">Punkte</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-[11px] font-black text-zinc-300 uppercase truncate group-hover:text-white transition-colors">
|
||||||
|
{t.bottles.name}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 pt-4 border-t border-white/5">
|
||||||
|
<div>
|
||||||
|
<div className="text-[8px] font-black text-zinc-500 uppercase tracking-widest mb-1">Guess</div>
|
||||||
|
<div className="text-[10px] font-bold text-purple-400">
|
||||||
|
{t.guess_abv ? `${t.guess_abv}%` : '-'} / {t.guess_age ? `${t.guess_age}y` : '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-[8px] font-black text-zinc-500 uppercase tracking-widest mb-1">Reality</div>
|
||||||
|
<div className="text-[10px] font-bold text-white">
|
||||||
|
{t.bottles.abv}% / {(t.bottles as any).age || '?'}y
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-zinc-800 pt-6">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 items-start">
|
||||||
<label className="text-[10px] font-black uppercase tracking-widest text-zinc-500 block mb-3">Buddy hinzufügen</label>
|
{/* Left Rail: Stats & Team */}
|
||||||
<select
|
<div className="lg:col-span-4 space-y-8 lg:sticky lg:top-12">
|
||||||
onChange={(e) => {
|
{/* ABV Analysis */}
|
||||||
if (e.target.value) handleAddParticipant(e.target.value);
|
|
||||||
e.target.value = "";
|
|
||||||
}}
|
|
||||||
className="w-full bg-zinc-800 border border-zinc-700 rounded-xl px-3 py-2 text-xs font-bold text-zinc-300 outline-none focus:ring-2 focus:ring-orange-500/50"
|
|
||||||
>
|
|
||||||
<option value="">Auswählen...</option>
|
|
||||||
{allBuddies
|
|
||||||
.filter(b => !participants.some(p => p.buddy_id === b.id))
|
|
||||||
.map(b => (
|
|
||||||
<option key={b.id} value={b.id}>{b.name}</option>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ABV Curve */}
|
|
||||||
{tastings.length > 0 && (
|
{tastings.length > 0 && (
|
||||||
<SessionABVCurve
|
<SessionABVCurve
|
||||||
tastings={tastings.map(t => ({
|
tastings={tastings.map(t => ({
|
||||||
@@ -407,33 +529,87 @@ export default function SessionDetailPage() {
|
|||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* Main Content: Bottle List */}
|
{/* Team */}
|
||||||
<section className="md:col-span-2 space-y-6">
|
<div className="bg-zinc-900 rounded-[32px] p-8 border border-white/5 shadow-2xl">
|
||||||
<div className="bg-zinc-900 rounded-3xl p-6 border border-zinc-800 shadow-lg">
|
<h3 className="text-[10px] font-black uppercase tracking-[0.3em] text-zinc-500 mb-8 flex items-center justify-between">
|
||||||
<div className="flex justify-between items-center mb-8">
|
<span className="flex items-center gap-2">
|
||||||
<h3 className="text-sm font-black uppercase tracking-widest text-zinc-500 flex items-center gap-2">
|
<Users size={14} className="text-orange-600" />
|
||||||
<GlassWater size={16} className="text-orange-600" />
|
Crew
|
||||||
Verkostete Flaschen
|
</span>
|
||||||
|
<span className="opacity-50">{participants.length}</span>
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex gap-2">
|
|
||||||
|
<div className="space-y-4 mb-8">
|
||||||
|
{participants.length === 0 ? (
|
||||||
|
<p className="text-[10px] text-zinc-600 font-bold uppercase italic tracking-wider">Noch keiner an Bord...</p>
|
||||||
|
) : (
|
||||||
|
participants.map((p) => (
|
||||||
|
<div key={p.buddy_id} className="group flex items-center justify-between p-3 rounded-2xl hover:bg-white/5 transition-colors border border-transparent hover:border-white/5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-zinc-800 flex items-center justify-center text-[10px] font-black text-orange-500 border border-white/5 uppercase">
|
||||||
|
{p.buddies.name[0]}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-black text-zinc-200">{p.buddies.name}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveParticipant(p.buddy_id)}
|
||||||
|
className="p-2 text-zinc-700 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-8 border-t border-white/5">
|
||||||
|
<p className="text-[8px] font-black uppercase tracking-widest text-zinc-600 mb-4 ml-1">Buddy hinzufügen</p>
|
||||||
|
<select
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value) handleAddParticipant(e.target.value);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
className="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-4 py-3 text-[10px] font-black uppercase tracking-wider text-zinc-400 outline-hidden focus:border-orange-500/50 transition-colors appearance-none"
|
||||||
|
style={{ backgroundImage: 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' fill=\'none\' viewBox=\'0 0 24 24\' stroke=\'%23a1a1aa\'%3E%3Cpath stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M19 9l-7 7-7-7\'/%3E%3C/svg%3E")', backgroundRepeat: 'no-repeat', backgroundPosition: 'right 1rem center', backgroundSize: '1rem' }}
|
||||||
|
>
|
||||||
|
<option value="">Auswahl...</option>
|
||||||
|
{allBuddies
|
||||||
|
.filter(b => !participants.some(p => p.buddy_id === b.id))
|
||||||
|
.map(b => (
|
||||||
|
<option key={b.id} value={b.id}>{b.name}</option>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Feed: Timeline */}
|
||||||
|
<div className="lg:col-span-8 space-y-8">
|
||||||
|
<section className="bg-zinc-900 rounded-[40px] p-8 md:p-12 border border-white/5 shadow-2xl min-h-screen">
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-6 mb-12">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-[10px] font-black uppercase tracking-[0.4em] text-orange-600">Timeline</h3>
|
||||||
|
<p className="text-2xl font-black text-white tracking-tight">Verkostungs-Historie</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 w-full md:w-auto">
|
||||||
{!session.ended_at && (
|
{!session.ended_at && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsBulkScanOpen(true)}
|
onClick={() => setIsBulkScanOpen(true)}
|
||||||
className="bg-zinc-800 hover:bg-zinc-700 text-orange-500 px-4 py-2 rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 transition-all border border-zinc-700"
|
className="flex-1 md:flex-none bg-zinc-950 border border-white/5 hover:border-orange-500/30 text-zinc-400 hover:text-orange-500 px-5 py-3 rounded-2xl text-[10px] font-black uppercase tracking-widest flex items-center justify-center gap-2 transition-all group"
|
||||||
>
|
>
|
||||||
<Zap size={16} />
|
<Zap size={14} className="group-hover:animate-pulse" />
|
||||||
Bulk Scan
|
Bulk Scan
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<Link
|
<button
|
||||||
href={`/?session_id=${id}`}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
className="bg-orange-600 hover:bg-orange-700 text-white px-4 py-2 rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 transition-all shadow-lg shadow-orange-600/20"
|
className="flex-1 md:flex-none bg-orange-600 hover:bg-orange-500 text-white px-6 py-3 rounded-2xl text-[10px] font-black uppercase tracking-widest flex items-center justify-center gap-2 transition-all shadow-xl shadow-orange-950/20"
|
||||||
>
|
>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
Flasche
|
Flasche
|
||||||
</Link>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -447,24 +623,38 @@ export default function SessionDetailPage() {
|
|||||||
tags: t.tasting_tags?.map((tg: any) => tg.tags.name) || [],
|
tags: t.tasting_tags?.map((tg: any) => tg.tags.name) || [],
|
||||||
category: t.bottles.category
|
category: t.bottles.category
|
||||||
}))}
|
}))}
|
||||||
sessionStart={session.scheduled_at} // Fallback to scheduled time if no started_at
|
sessionStart={session.scheduled_at}
|
||||||
|
isBlind={session.is_blind}
|
||||||
|
isRevealed={session.is_revealed}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Bulk Scan Sheet */}
|
|
||||||
<BulkScanSheet
|
<BulkScanSheet
|
||||||
isOpen={isBulkScanOpen}
|
isOpen={isBulkScanOpen}
|
||||||
onClose={() => setIsBulkScanOpen(false)}
|
onClose={() => setIsBulkScanOpen(false)}
|
||||||
sessionId={id as string}
|
sessionId={id as string}
|
||||||
sessionName={session.name}
|
sessionName={session.name}
|
||||||
onSuccess={(bottleIds) => {
|
onSuccess={() => {
|
||||||
setIsBulkScanOpen(false);
|
setIsBulkScanOpen(false);
|
||||||
fetchSessionData();
|
fetchSessionData();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ScanAndTasteFlow
|
||||||
|
isOpen={isScanFlowOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsScanFlowOpen(false);
|
||||||
|
setSelectedFile(null);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
}}
|
||||||
|
imageFile={selectedFile}
|
||||||
|
onBottleSaved={() => {
|
||||||
|
fetchSessionData();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
288
src/app/sessions/page.tsx
Normal file
288
src/app/sessions/page.tsx
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { ArrowLeft, Calendar, Plus, GlassWater, Loader2, ChevronRight, Users, Sparkles, Clock, Trash2 } from 'lucide-react';
|
||||||
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useSession } from '@/context/SessionContext';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import { deleteSession } from '@/services/delete-session';
|
||||||
|
import AvatarStack from '@/components/AvatarStack';
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
scheduled_at: string;
|
||||||
|
ended_at?: string;
|
||||||
|
participant_count?: number;
|
||||||
|
whisky_count?: number;
|
||||||
|
participants?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SessionsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
const supabase = createClient();
|
||||||
|
const { activeSession, setActiveSession } = useSession();
|
||||||
|
const { user, isLoading: isAuthLoading } = useAuth();
|
||||||
|
|
||||||
|
const [sessions, setSessions] = useState<Session[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [isDeleting, setIsDeleting] = useState<string | null>(null);
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthLoading && user) {
|
||||||
|
fetchSessions();
|
||||||
|
}
|
||||||
|
}, [user, isAuthLoading]);
|
||||||
|
|
||||||
|
const fetchSessions = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('tasting_sessions')
|
||||||
|
.select(`
|
||||||
|
*,
|
||||||
|
session_participants (buddies(name)),
|
||||||
|
tastings (count)
|
||||||
|
`)
|
||||||
|
.order('scheduled_at', { ascending: false });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error fetching sessions:', error);
|
||||||
|
// Fallback without tastings join
|
||||||
|
const { data: fallbackData } = await supabase
|
||||||
|
.from('tasting_sessions')
|
||||||
|
.select(`*, session_participants (buddies(name))`)
|
||||||
|
.order('scheduled_at', { ascending: false });
|
||||||
|
|
||||||
|
if (fallbackData) {
|
||||||
|
setSessions(fallbackData.map(s => {
|
||||||
|
const participants = (s.session_participants as any[])?.filter(p => p.buddies).map(p => p.buddies.name) || [];
|
||||||
|
return { ...s, participant_count: participants.length, participants, whisky_count: 0 };
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSessions(data?.map(s => {
|
||||||
|
const participants = (s.session_participants as any[])?.filter(p => p.buddies).map(p => p.buddies.name) || [];
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
participant_count: participants.length,
|
||||||
|
participants,
|
||||||
|
whisky_count: (s.tastings as any[])?.[0]?.count || 0
|
||||||
|
};
|
||||||
|
}) || []);
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateSession = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newName.trim()) return;
|
||||||
|
|
||||||
|
setIsCreating(true);
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('tasting_sessions')
|
||||||
|
.insert({ name: newName.trim(), scheduled_at: new Date().toISOString() })
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!error && data) {
|
||||||
|
setSessions(prev => [{ ...data, participant_count: 0, whisky_count: 0 }, ...prev]);
|
||||||
|
setNewName('');
|
||||||
|
setShowCreateForm(false);
|
||||||
|
setActiveSession({ id: data.id, name: data.name });
|
||||||
|
}
|
||||||
|
setIsCreating(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteSession = async (id: string) => {
|
||||||
|
setIsDeleting(id);
|
||||||
|
const result = await deleteSession(id);
|
||||||
|
if (result.success) {
|
||||||
|
setSessions(prev => prev.filter(s => s.id !== id));
|
||||||
|
if (activeSession?.id === id) {
|
||||||
|
setActiveSession(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsDeleting(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date: string) => {
|
||||||
|
return new Date(date).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeSessions = sessions.filter(s => !s.ended_at);
|
||||||
|
const pastSessions = sessions.filter(s => s.ended_at);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-zinc-950 pb-24">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="sticky top-0 z-20 bg-zinc-950/95 backdrop-blur-md border-b border-zinc-900">
|
||||||
|
<div className="max-w-2xl mx-auto px-4 py-4 flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="p-2 rounded-xl bg-zinc-900 border border-zinc-800 text-zinc-400 hover:text-white hover:border-zinc-700 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
</button>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="text-xl font-bold text-white">
|
||||||
|
{t('session.title')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
{sessions.length} Sessions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateForm(!showCreateForm)}
|
||||||
|
className="p-2.5 bg-orange-600 hover:bg-orange-700 rounded-xl text-white transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-2xl mx-auto px-4 pt-6">
|
||||||
|
{/* Create Form */}
|
||||||
|
{showCreateForm && (
|
||||||
|
<form onSubmit={handleCreateSession} className="mb-6 p-4 bg-zinc-900 rounded-2xl border border-zinc-800 animate-in fade-in slide-in-from-top-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
placeholder={t('session.sessionName')}
|
||||||
|
className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600 mb-3"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCreateForm(false)}
|
||||||
|
className="flex-1 py-2 bg-zinc-800 text-zinc-400 rounded-xl text-sm font-bold"
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isCreating || !newName.trim()}
|
||||||
|
className="flex-1 py-2 bg-orange-600 hover:bg-orange-700 text-white rounded-xl text-sm font-bold disabled:opacity-50 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isCreating ? <Loader2 size={16} className="animate-spin" /> : <Sparkles size={16} />}
|
||||||
|
Start Session
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Active Session Banner */}
|
||||||
|
{activeSession && (
|
||||||
|
<Link href={`/sessions/${activeSession.id}`}>
|
||||||
|
<div className="mb-6 p-4 bg-linear-to-r from-orange-600/20 to-orange-500/10 rounded-2xl border border-orange-600/30 flex items-center gap-4 group hover:border-orange-500/50 transition-colors">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-orange-600/20 flex items-center justify-center">
|
||||||
|
<Sparkles size={24} className="text-orange-500" />
|
||||||
|
</div>
|
||||||
|
<span className="absolute -top-1 -right-1 w-3 h-3 bg-orange-500 rounded-full animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-widest text-orange-500/80">
|
||||||
|
Live Session
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-bold text-white">
|
||||||
|
{activeSession.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight size={20} className="text-orange-500 group-hover:translate-x-1 transition-transform" />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sessions List */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-20">
|
||||||
|
<Loader2 size={32} className="animate-spin text-orange-600" />
|
||||||
|
</div>
|
||||||
|
) : sessions.length === 0 ? (
|
||||||
|
<div className="text-center py-20">
|
||||||
|
<div className="w-16 h-16 mx-auto rounded-2xl bg-zinc-900 flex items-center justify-center mb-4">
|
||||||
|
<Calendar size={32} className="text-zinc-600" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-white mb-2">No Sessions Yet</p>
|
||||||
|
<p className="text-sm text-zinc-500 max-w-xs mx-auto">
|
||||||
|
Start your first tasting session to track what you're drinking.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sessions.map(session => (
|
||||||
|
<div
|
||||||
|
key={session.id}
|
||||||
|
className={`p-4 bg-zinc-900 rounded-2xl border transition-all group ${activeSession?.id === session.id
|
||||||
|
? 'border-orange-600/50'
|
||||||
|
: 'border-zinc-800 hover:border-zinc-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${session.ended_at
|
||||||
|
? 'bg-zinc-800 text-zinc-500'
|
||||||
|
: 'bg-orange-600/20 text-orange-500'
|
||||||
|
}`}>
|
||||||
|
<GlassWater size={24} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<Link href={`/sessions/${session.id}`}>
|
||||||
|
<h3 className="font-bold text-white truncate hover:text-orange-500 transition-colors">
|
||||||
|
{session.name}
|
||||||
|
</h3>
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-zinc-500 mt-0.5">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock size={12} />
|
||||||
|
{formatDate(session.scheduled_at)}
|
||||||
|
</span>
|
||||||
|
{session.whisky_count ? (
|
||||||
|
<span>{session.whisky_count} Whiskys</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{session.participants && session.participants.length > 0 && (
|
||||||
|
<AvatarStack names={session.participants} limit={3} size="sm" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteSession(session.id)}
|
||||||
|
disabled={isDeleting === session.id}
|
||||||
|
className="p-2 text-zinc-600 hover:text-red-500 hover:bg-zinc-800 rounded-lg opacity-0 group-hover:opacity-100 transition-all"
|
||||||
|
>
|
||||||
|
{isDeleting === session.id ? (
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 size={16} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<Link href={`/sessions/${session.id}`}>
|
||||||
|
<button className="p-2 text-zinc-500 hover:text-white hover:bg-zinc-800 rounded-lg transition-colors">
|
||||||
|
<ChevronRight size={18} />
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -135,7 +135,7 @@ export default function SplitPublicPage() {
|
|||||||
alt={split.bottle.name}
|
alt={split.bottle.name}
|
||||||
className="w-full h-full object-cover opacity-80"
|
className="w-full h-full object-cover opacity-80"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-zinc-900 via-transparent to-transparent" />
|
<div className="absolute inset-0 bg-linear-to-t from-zinc-900 via-transparent to-transparent" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ export default function SplitManagePage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<p className="font-bold text-white truncate">{split.bottleName}</p>
|
<p className="font-bold text-white truncate">{split.bottleName}</p>
|
||||||
{!split.isActive && (
|
{!split.isActive && (
|
||||||
<span className="px-2 py-0.5 bg-zinc-800 rounded text-[10px] font-bold text-zinc-500">
|
<span className="px-2 py-0.5 bg-zinc-800 rounded-sm text-[10px] font-bold text-zinc-500">
|
||||||
Geschlossen
|
Geschlossen
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
84
src/app/stats/page.tsx
Normal file
84
src/app/stats/page.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
import AnalyticsDashboard from '@/components/AnalyticsDashboard';
|
||||||
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import { ChartSkeleton, StatsCardSkeleton } from '@/components/Skeletons';
|
||||||
|
|
||||||
|
export default function StatsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [bottles, setBottles] = useState<any[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const supabase = createClient();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchBottles = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('bottles')
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
distillery,
|
||||||
|
purchase_price,
|
||||||
|
category,
|
||||||
|
abv,
|
||||||
|
age,
|
||||||
|
status,
|
||||||
|
tastings (rating)
|
||||||
|
`);
|
||||||
|
setBottles(data || []);
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
fetchBottles();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-zinc-950 p-4 pb-24">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4 mb-8">
|
||||||
|
<button
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="p-2 rounded-xl bg-zinc-900 border border-zinc-800 text-zinc-400 hover:text-white hover:border-zinc-700 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">
|
||||||
|
{t('home.stats.title')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-zinc-500">
|
||||||
|
Deep dive into your collection
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dashboard */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* KPI Cards Skeleton */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<StatsCardSkeleton />
|
||||||
|
<StatsCardSkeleton />
|
||||||
|
<StatsCardSkeleton />
|
||||||
|
<StatsCardSkeleton />
|
||||||
|
</div>
|
||||||
|
{/* Charts Skeleton */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<ChartSkeleton height={300} />
|
||||||
|
<ChartSkeleton height={300} />
|
||||||
|
</div>
|
||||||
|
<ChartSkeleton height={400} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<AnalyticsDashboard bottles={bottles} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/app/wishlist/page.tsx
Normal file
47
src/app/wishlist/page.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { ArrowLeft, Heart, Plus } from 'lucide-react';
|
||||||
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
|
|
||||||
|
export default function WishlistPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-zinc-950 p-4 pb-24">
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4 mb-8">
|
||||||
|
<button
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="p-2 rounded-xl bg-zinc-900 border border-zinc-800 text-zinc-400 hover:text-white hover:border-zinc-700 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">
|
||||||
|
{t('nav.wishlist')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-zinc-500">
|
||||||
|
Bottles you want to try
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||||
|
<div className="p-4 bg-zinc-900 rounded-full mb-4">
|
||||||
|
<Heart size={32} className="text-zinc-600" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-bold text-white mb-2">
|
||||||
|
Coming Soon
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-zinc-500 max-w-xs">
|
||||||
|
Your wishlist will appear here. You'll be able to save bottles you want to try in the future.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,43 +6,62 @@ import { GlassWater, Square, ArrowRight, Sparkles } from 'lucide-react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useI18n } from '@/i18n/I18nContext';
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
|
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
export default function ActiveSessionBanner() {
|
export default function ActiveSessionBanner() {
|
||||||
const { activeSession, setActiveSession } = useSession();
|
const { activeSession, setActiveSession } = useSession();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
if (!activeSession) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed top-0 left-0 right-0 z-[100] animate-in slide-in-from-top duration-500">
|
<AnimatePresence>
|
||||||
<div className="bg-orange-600 text-white px-4 py-2 flex items-center justify-between shadow-lg">
|
{activeSession && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: 50, opacity: 0, x: '-50%' }}
|
||||||
|
animate={{ y: 0, opacity: 1, x: '-50%' }}
|
||||||
|
exit={{ y: 50, opacity: 0, x: '-50%' }}
|
||||||
|
className="fixed bottom-32 left-1/2 z-50 w-[calc(100%-2rem)] max-w-sm"
|
||||||
|
>
|
||||||
|
<div className="bg-zinc-900/90 backdrop-blur-2xl border border-orange-500/20 rounded-[32px] p-2 flex items-center justify-between shadow-2xl ring-1 ring-white/5 overflow-hidden">
|
||||||
|
{/* Session Info Link */}
|
||||||
<Link
|
<Link
|
||||||
href={`/sessions/${activeSession.id}`}
|
href={`/sessions/${activeSession.id}`}
|
||||||
className="flex items-center gap-3 flex-1 min-w-0"
|
className="flex items-center gap-3 px-3 py-2 flex-1 min-w-0 hover:bg-white/5 rounded-2xl transition-colors"
|
||||||
>
|
>
|
||||||
<div className="relative shrink-0">
|
<div className="relative shrink-0">
|
||||||
<div className="bg-white/20 p-1.5 rounded-lg">
|
<div className="bg-orange-600/10 p-2.5 rounded-2xl border border-orange-500/20">
|
||||||
<Sparkles size={16} className="text-white animate-pulse" />
|
<Sparkles size={16} className="text-orange-500" />
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute -top-1 -right-1 w-2.5 h-2.5 bg-red-500 rounded-full border-2 border-orange-600 animate-ping" />
|
<div className="absolute -top-1 -right-1 w-3 h-3 bg-orange-600 rounded-full border-2 border-zinc-900 animate-pulse shadow-[0_0_8px_rgba(234,88,12,0.6)]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-0.5">
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
<span className="text-[9px] font-black uppercase tracking-widest bg-white/20 px-1.5 py-0.5 rounded leading-none text-white whitespace-nowrap">Live Jetzt</span>
|
<span className="text-[8px] font-black uppercase tracking-widest text-orange-600 animate-pulse">Live</span>
|
||||||
<p className="text-[10px] font-black uppercase tracking-wider opacity-90 leading-none truncate">{t('session.activeSession')}</p>
|
<p className="text-[9px] font-bold uppercase tracking-wider text-zinc-500 truncate leading-none">{t('session.activeSession')}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-bold truncate leading-none">{activeSession.name}</p>
|
<p className="text-sm font-bold text-zinc-100 truncate leading-none tracking-tight">{activeSession.name}</p>
|
||||||
</div>
|
</div>
|
||||||
<ArrowRight size={14} className="opacity-50 ml-1 shrink-0" />
|
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex items-center gap-1 pr-1">
|
||||||
|
<Link
|
||||||
|
href={`/sessions/${activeSession.id}`}
|
||||||
|
className="p-3 text-zinc-400 hover:text-orange-500 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowRight size={18} />
|
||||||
|
</Link>
|
||||||
|
<div className="w-px h-8 bg-zinc-800 mx-1" />
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveSession(null)}
|
onClick={() => setActiveSession(null)}
|
||||||
className="ml-4 p-2 hover:bg-white/10 rounded-full transition-colors"
|
className="p-3 text-zinc-600 hover:text-red-500 transition-colors"
|
||||||
title="End Session"
|
title="End Session"
|
||||||
>
|
>
|
||||||
<Square size={20} fill="currentColor" />
|
<Square size={16} fill="currentColor" className="opacity-40" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
406
src/components/AnalyticsDashboard.tsx
Normal file
406
src/components/AnalyticsDashboard.tsx
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||||
|
PieChart, Pie, Cell, ScatterChart, Scatter, ZAxis, Legend, AreaChart, Area
|
||||||
|
} from 'recharts';
|
||||||
|
import { TrendingUp, CreditCard, Star, Home, BarChart3, Droplets, Clock, Activity, DollarSign } from 'lucide-react';
|
||||||
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
|
|
||||||
|
interface Bottle {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
distillery?: string;
|
||||||
|
purchase_price?: number | null;
|
||||||
|
rating?: number | null; // Calculated avg rating
|
||||||
|
category?: string;
|
||||||
|
abv?: number;
|
||||||
|
age?: number;
|
||||||
|
status?: string | null;
|
||||||
|
tastings?: { rating: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnalyticsDashboardProps {
|
||||||
|
bottles: Bottle[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom Colors
|
||||||
|
const COLORS = [
|
||||||
|
'#f97316', // Orange 500
|
||||||
|
'#a855f7', // Purple 500
|
||||||
|
'#3b82f6', // Blue 500
|
||||||
|
'#10b981', // Emerald 500
|
||||||
|
'#ef4444', // Red 500
|
||||||
|
'#eab308', // Yellow 500
|
||||||
|
'#ec4899', // Pink 500
|
||||||
|
'#6366f1', // Indigo 500
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AnalyticsDashboard({ bottles }: AnalyticsDashboardProps) {
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
|
||||||
|
// --- Process Data ---
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const enrichedBottles = bottles.map(b => {
|
||||||
|
const ratings = b.tastings?.map(t => t.rating) || [];
|
||||||
|
const avgRating = ratings.length > 0
|
||||||
|
? ratings.reduce((a, b) => a + b, 0) / ratings.length
|
||||||
|
: null;
|
||||||
|
return { ...b, rating: avgRating };
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. High Level Metrics
|
||||||
|
const totalValue = enrichedBottles.reduce((sum, b) => sum + (Number(b.purchase_price) || 0), 0);
|
||||||
|
const bottlesWithRating = enrichedBottles.filter(b => b.rating !== null);
|
||||||
|
const avgCollectionRating = bottlesWithRating.length > 0
|
||||||
|
? bottlesWithRating.reduce((sum, b) => sum + (b.rating || 0), 0) / bottlesWithRating.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// 2. Category Distribution
|
||||||
|
const catMap = new Map<string, number>();
|
||||||
|
enrichedBottles.forEach(b => {
|
||||||
|
const cat = b.category || 'Unknown';
|
||||||
|
catMap.set(cat, (catMap.get(cat) || 0) + 1);
|
||||||
|
});
|
||||||
|
const categoryData = Array.from(catMap.entries())
|
||||||
|
.map(([name, value]) => ({ name, value }))
|
||||||
|
.sort((a, b) => b.value - a.value);
|
||||||
|
|
||||||
|
// 3. Status Distribution
|
||||||
|
const statusMap = new Map<string, number>();
|
||||||
|
enrichedBottles.forEach(b => {
|
||||||
|
const s = b.status || 'sealed';
|
||||||
|
statusMap.set(s, (statusMap.get(s) || 0) + 1);
|
||||||
|
});
|
||||||
|
const statusData = Array.from(statusMap.entries())
|
||||||
|
.map(([name, value]) => ({ name: name.charAt(0).toUpperCase() + name.slice(1), value }));
|
||||||
|
|
||||||
|
// 4. Distillery Top 10
|
||||||
|
const distMap = new Map<string, number>();
|
||||||
|
enrichedBottles.forEach(b => {
|
||||||
|
if (b.distillery) {
|
||||||
|
distMap.set(b.distillery, (distMap.get(b.distillery) || 0) + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const distilleryData = Array.from(distMap.entries())
|
||||||
|
.map(([name, value]) => ({ name, value }))
|
||||||
|
.sort((a, b) => b.value - a.value)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
// 5. ABV Buckets
|
||||||
|
const abvBuckets = {
|
||||||
|
'< 40%': 0,
|
||||||
|
'40-43%': 0,
|
||||||
|
'43-46%': 0,
|
||||||
|
'46-50%': 0,
|
||||||
|
'50-55%': 0,
|
||||||
|
'55-60%': 0,
|
||||||
|
'> 60%': 0,
|
||||||
|
};
|
||||||
|
enrichedBottles.forEach(b => {
|
||||||
|
if (!b.abv) return;
|
||||||
|
if (b.abv < 40) abvBuckets['< 40%']++;
|
||||||
|
else if (b.abv <= 43) abvBuckets['40-43%']++;
|
||||||
|
else if (b.abv <= 46) abvBuckets['43-46%']++;
|
||||||
|
else if (b.abv <= 50) abvBuckets['46-50%']++;
|
||||||
|
else if (b.abv <= 55) abvBuckets['50-55%']++;
|
||||||
|
else if (b.abv <= 60) abvBuckets['55-60%']++;
|
||||||
|
else abvBuckets['> 60%']++;
|
||||||
|
});
|
||||||
|
const abvData = Object.entries(abvBuckets).map(([name, value]) => ({ name, value }));
|
||||||
|
|
||||||
|
// 6. Age Buckets
|
||||||
|
const ageBuckets = {
|
||||||
|
'NAS': 0,
|
||||||
|
'< 10y': 0,
|
||||||
|
'10-12y': 0,
|
||||||
|
'13-18y': 0,
|
||||||
|
'19-25y': 0,
|
||||||
|
'> 25y': 0
|
||||||
|
};
|
||||||
|
enrichedBottles.forEach(b => {
|
||||||
|
if (!b.age) {
|
||||||
|
ageBuckets['NAS']++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (b.age < 10) ageBuckets['< 10y']++;
|
||||||
|
else if (b.age <= 12) ageBuckets['10-12y']++;
|
||||||
|
else if (b.age <= 18) ageBuckets['13-18y']++;
|
||||||
|
else if (b.age <= 25) ageBuckets['19-25y']++;
|
||||||
|
else ageBuckets['> 25y']++;
|
||||||
|
});
|
||||||
|
const ageData = Object.entries(ageBuckets).map(([name, value]) => ({ name, value }));
|
||||||
|
|
||||||
|
// 7. Price vs Quality
|
||||||
|
const scatterData = enrichedBottles
|
||||||
|
.filter(b => b.purchase_price && b.rating)
|
||||||
|
.map(b => ({
|
||||||
|
x: b.purchase_price,
|
||||||
|
y: b.rating,
|
||||||
|
name: b.name,
|
||||||
|
z: 1 // size
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalValue,
|
||||||
|
avgCollectionRating,
|
||||||
|
totalCount: bottles.length,
|
||||||
|
topDistillery: distilleryData[0]?.name || 'N/A',
|
||||||
|
categoryData,
|
||||||
|
statusData,
|
||||||
|
distilleryData,
|
||||||
|
abvData,
|
||||||
|
ageData,
|
||||||
|
scatterData
|
||||||
|
};
|
||||||
|
}, [bottles]);
|
||||||
|
|
||||||
|
|
||||||
|
// Helper for Custom Tooltip
|
||||||
|
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
return (
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 p-3 rounded-xl shadow-xl">
|
||||||
|
<p className="font-bold text-white mb-1">{label}</p>
|
||||||
|
{payload.map((entry: any, index: number) => (
|
||||||
|
<p key={index} className="text-sm" style={{ color: entry.color }}>
|
||||||
|
{entry.name}: {entry.value}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scatter Tooltip
|
||||||
|
const ScatterTooltip = ({ active, payload }: any) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
const data = payload[0].payload;
|
||||||
|
return (
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 p-3 rounded-xl shadow-xl max-w-[200px]">
|
||||||
|
<p className="font-bold text-white mb-1 text-xs">{data.name}</p>
|
||||||
|
<p className="text-xs text-zinc-400">Price: <span className="text-green-500 font-bold">{data.x}€</span></p>
|
||||||
|
<p className="text-xs text-zinc-400">Rating: <span className="text-orange-500 font-bold">{data.y?.toFixed(1)}</span></p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-top-4 duration-500">
|
||||||
|
|
||||||
|
{/* KPI Cards */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 p-4 rounded-2xl">
|
||||||
|
<div className="flex items-center gap-2 mb-2 text-zinc-500">
|
||||||
|
<CreditCard size={18} className="text-green-500" />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider">Total Value</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl md:text-3xl font-black text-white">
|
||||||
|
{stats.totalValue.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 p-4 rounded-2xl">
|
||||||
|
<div className="flex items-center gap-2 mb-2 text-zinc-500">
|
||||||
|
<Home size={18} className="text-blue-500" />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider">Bottles</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl md:text-3xl font-black text-white">
|
||||||
|
{stats.totalCount}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 p-4 rounded-2xl">
|
||||||
|
<div className="flex items-center gap-2 mb-2 text-zinc-500">
|
||||||
|
<Star size={18} className="text-orange-500" />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider">Avg Rating</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl md:text-3xl font-black text-white">
|
||||||
|
{stats.avgCollectionRating.toFixed(1)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 p-4 rounded-2xl">
|
||||||
|
<div className="flex items-center gap-2 mb-2 text-zinc-500">
|
||||||
|
<Activity size={18} className="text-purple-500" />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider">Favorite</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl md:text-2xl font-black text-white truncate" title={stats.topDistillery}>
|
||||||
|
{stats.topDistillery}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 1: Categories & Status */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
|
||||||
|
{/* Category Distribution */}
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 p-6 rounded-2xl">
|
||||||
|
<h3 className="font-bold text-white mb-6 flex items-center gap-2">
|
||||||
|
<BarChart3 size={20} className="text-zinc-500" />
|
||||||
|
Categories
|
||||||
|
</h3>
|
||||||
|
<div className="h-[300px] w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={stats.categoryData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={60}
|
||||||
|
outerRadius={100}
|
||||||
|
paddingAngle={5}
|
||||||
|
dataKey="value"
|
||||||
|
stroke="none"
|
||||||
|
>
|
||||||
|
{stats.categoryData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Legend wrapperStyle={{ fontSize: '12px', paddingTop: '20px' }} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Distribution */}
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 p-6 rounded-2xl">
|
||||||
|
<h3 className="font-bold text-white mb-6 flex items-center gap-2">
|
||||||
|
<Activity size={20} className="text-zinc-500" />
|
||||||
|
Status
|
||||||
|
</h3>
|
||||||
|
<div className="h-[300px] w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={stats.statusData} layout="vertical" margin={{ left: 20 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="#27272a" />
|
||||||
|
<XAxis type="number" stroke="#52525b" fontSize={12} />
|
||||||
|
<YAxis dataKey="name" type="category" stroke="#a1a1aa" fontSize={12} width={80} />
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Bar dataKey="value" name="Bottles" radius={[0, 4, 4, 0]}>
|
||||||
|
{stats.statusData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: Distillery Top 10 */}
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 p-6 rounded-2xl">
|
||||||
|
<h3 className="font-bold text-white mb-6 flex items-center gap-2">
|
||||||
|
<Home size={20} className="text-zinc-500" />
|
||||||
|
Top Distilleries
|
||||||
|
</h3>
|
||||||
|
<div className="h-[300px] w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={stats.distilleryData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#27272a" />
|
||||||
|
<XAxis dataKey="name" stroke="#a1a1aa" fontSize={12} interval={0} angle={-45} textAnchor="end" height={60} />
|
||||||
|
<YAxis stroke="#52525b" fontSize={12} />
|
||||||
|
<Tooltip content={<CustomTooltip />} cursor={{ fill: '#27272a' }} />
|
||||||
|
<Bar dataKey="value" name="Bottles" fill="#f97316" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 3: Technical Specs (ABV & Age) */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
|
||||||
|
{/* ABV */}
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 p-6 rounded-2xl">
|
||||||
|
<h3 className="font-bold text-white mb-6 flex items-center gap-2">
|
||||||
|
<Droplets size={20} className="text-zinc-500" />
|
||||||
|
Strength (ABV)
|
||||||
|
</h3>
|
||||||
|
<div className="h-[250px] w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={stats.abvData}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorAbv" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#8b5cf6" stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor="#8b5cf6" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#27272a" />
|
||||||
|
<XAxis dataKey="name" stroke="#a1a1aa" fontSize={10} />
|
||||||
|
<YAxis stroke="#52525b" fontSize={10} />
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Area type="monotone" dataKey="value" name="Bottles" stroke="#8b5cf6" fillOpacity={1} fill="url(#colorAbv)" />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Age */}
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 p-6 rounded-2xl">
|
||||||
|
<h3 className="font-bold text-white mb-6 flex items-center gap-2">
|
||||||
|
<Clock size={20} className="text-zinc-500" />
|
||||||
|
Age Statements
|
||||||
|
</h3>
|
||||||
|
<div className="h-[250px] w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={stats.ageData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#27272a" />
|
||||||
|
<XAxis dataKey="name" stroke="#a1a1aa" fontSize={10} />
|
||||||
|
<YAxis stroke="#52525b" fontSize={10} />
|
||||||
|
<Tooltip content={<CustomTooltip />} cursor={{ fill: '#27272a' }} />
|
||||||
|
<Bar dataKey="value" name="Bottles" fill="#10b981" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 4: Price vs Quality */}
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 p-6 rounded-2xl">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="font-bold text-white flex items-center gap-2">
|
||||||
|
<DollarSign size={20} className="text-zinc-500" />
|
||||||
|
Price vs. Quality
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-zinc-500 px-2 py-1 bg-zinc-800 rounded-lg">Excludes free/unrated bottles</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-zinc-500 mb-6">
|
||||||
|
Find hidden gems: Low price (left) but high rating (top).
|
||||||
|
</p>
|
||||||
|
<div className="h-[400px] w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#27272a" />
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
dataKey="x"
|
||||||
|
name="Price"
|
||||||
|
unit="€"
|
||||||
|
stroke="#52525b"
|
||||||
|
fontSize={12}
|
||||||
|
label={{ value: 'Price (€)', position: 'insideBottom', offset: -10, fill: '#71717a', fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
type="number"
|
||||||
|
dataKey="y"
|
||||||
|
name="Rating"
|
||||||
|
domain={[0, 100]}
|
||||||
|
stroke="#52525b"
|
||||||
|
fontSize={12}
|
||||||
|
label={{ value: 'Rating (0-100)', angle: -90, position: 'insideLeft', fill: '#71717a', fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<ZAxis type="number" dataKey="z" range={[50, 400]} />
|
||||||
|
<Tooltip content={<ScatterTooltip />} cursor={{ strokeDasharray: '3 3' }} />
|
||||||
|
<Scatter name="Bottles" data={stats.scatterData} fill="#ec4899" fillOpacity={0.6} stroke="#fff" strokeWidth={1} />
|
||||||
|
</ScatterChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -133,7 +133,7 @@ export default function AuthForm() {
|
|||||||
placeholder="dein_username"
|
placeholder="dein_username"
|
||||||
required
|
required
|
||||||
maxLength={20}
|
maxLength={20}
|
||||||
className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl focus:ring-1 focus:ring-orange-600 focus:border-transparent outline-none transition-all text-white placeholder:text-zinc-600"
|
className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl focus:ring-1 focus:ring-orange-600 focus:border-transparent outline-hidden transition-all text-white placeholder:text-zinc-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-zinc-600 ml-1">Nur Kleinbuchstaben, Zahlen und _</p>
|
<p className="text-[10px] text-zinc-600 ml-1">Nur Kleinbuchstaben, Zahlen und _</p>
|
||||||
@@ -149,7 +149,7 @@ export default function AuthForm() {
|
|||||||
onChange={(e) => setFullName(e.target.value)}
|
onChange={(e) => setFullName(e.target.value)}
|
||||||
placeholder="Max Mustermann"
|
placeholder="Max Mustermann"
|
||||||
maxLength={50}
|
maxLength={50}
|
||||||
className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl focus:ring-1 focus:ring-orange-600 focus:border-transparent outline-none transition-all text-white placeholder:text-zinc-600"
|
className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl focus:ring-1 focus:ring-orange-600 focus:border-transparent outline-hidden transition-all text-white placeholder:text-zinc-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -166,7 +166,7 @@ export default function AuthForm() {
|
|||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="name@beispiel.de"
|
placeholder="name@beispiel.de"
|
||||||
required
|
required
|
||||||
className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl focus:ring-1 focus:ring-orange-600 focus:border-transparent outline-none transition-all text-white placeholder:text-zinc-600"
|
className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl focus:ring-1 focus:ring-orange-600 focus:border-transparent outline-hidden transition-all text-white placeholder:text-zinc-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -181,7 +181,7 @@ export default function AuthForm() {
|
|||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
required
|
required
|
||||||
className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl focus:ring-1 focus:ring-orange-600 focus:border-transparent outline-none transition-all text-white placeholder:text-zinc-600"
|
className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl focus:ring-1 focus:ring-orange-600 focus:border-transparent outline-hidden transition-all text-white placeholder:text-zinc-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default function AvatarStack({ names, limit = 3, size = 'sm' }: AvatarSta
|
|||||||
{visibleNames.map((name, i) => (
|
{visibleNames.map((name, i) => (
|
||||||
<div
|
<div
|
||||||
key={`${name}-${i}`}
|
key={`${name}-${i}`}
|
||||||
className={`${sizeClasses} rounded-full bg-orange-900/30 border-2 border-zinc-950 flex items-center justify-center text-orange-400 font-bold ring-1 ring-orange-500/10 shadow-sm relative group`}
|
className={`${sizeClasses} rounded-full bg-orange-900/30 border-2 border-zinc-950 flex items-center justify-center text-orange-400 font-bold ring-1 ring-orange-500/10 shadow-xs relative group`}
|
||||||
title={name}
|
title={name}
|
||||||
>
|
>
|
||||||
{getInitials(name)}
|
{getInitials(name)}
|
||||||
@@ -42,7 +42,7 @@ export default function AvatarStack({ names, limit = 3, size = 'sm' }: AvatarSta
|
|||||||
))}
|
))}
|
||||||
{extraCount > 0 && (
|
{extraCount > 0 && (
|
||||||
<div
|
<div
|
||||||
className={`${sizeClasses} rounded-full bg-zinc-100 dark:bg-zinc-800 border-2 border-white dark:border-zinc-900 flex items-center justify-center text-zinc-500 dark:text-zinc-400 font-black ring-1 ring-zinc-500/10 shadow-sm relative group`}
|
className={`${sizeClasses} rounded-full bg-zinc-100 dark:bg-zinc-800 border-2 border-white dark:border-zinc-900 flex items-center justify-center text-zinc-500 dark:text-zinc-400 font-black ring-1 ring-zinc-500/10 shadow-xs relative group`}
|
||||||
title={`${extraCount} weitere Personen`}
|
title={`${extraCount} weitere Personen`}
|
||||||
>
|
>
|
||||||
+{extraCount}
|
+{extraCount}
|
||||||
|
|||||||
66
src/components/BackgroundRemovalHandler.tsx
Normal file
66
src/components/BackgroundRemovalHandler.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useImageProcessor } from '@/hooks/useImageProcessor';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { FEATURES } from '@/config/features';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global handler for background AI image processing.
|
||||||
|
* Mount this in root layout to ensure processing continues in background.
|
||||||
|
* It also scans for unprocessed local images on load.
|
||||||
|
*/
|
||||||
|
export default function BackgroundRemovalHandler() {
|
||||||
|
const { addToQueue } = useImageProcessor();
|
||||||
|
const hasScannedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!FEATURES.ENABLE_AI_BG_REMOVAL) return;
|
||||||
|
if (hasScannedRef.current) return;
|
||||||
|
hasScannedRef.current = true;
|
||||||
|
|
||||||
|
const scanAndQueue = async () => {
|
||||||
|
try {
|
||||||
|
// 1. Check pending_scans (offline scans)
|
||||||
|
const pendingScans = await db.pending_scans
|
||||||
|
.filter(scan => !scan.bgRemoved)
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
for (const scan of pendingScans) {
|
||||||
|
if (scan.imageBase64 && scan.temp_id) {
|
||||||
|
// Convert base64 back to blob for the worker
|
||||||
|
const res = await fetch(scan.imageBase64);
|
||||||
|
const blob = await res.blob();
|
||||||
|
addToQueue(scan.temp_id, blob);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check cache_bottles (successfully saved bottles)
|
||||||
|
const cachedBottles = await db.cache_bottles
|
||||||
|
.filter(bottle => !bottle.bgRemoved)
|
||||||
|
.limit(10) // Limit to avoid overwhelming on start
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
for (const bottle of cachedBottles) {
|
||||||
|
if (bottle.image_url && bottle.id) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(bottle.image_url);
|
||||||
|
const blob = await res.blob();
|
||||||
|
addToQueue(bottle.id, blob);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[BG-Removal] Failed to fetch image for bottle ${bottle.id}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[BG-Removal] Initial scan error:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delay slightly to not block initial app boot
|
||||||
|
const timer = setTimeout(scanAndQueue, 3000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [addToQueue]);
|
||||||
|
|
||||||
|
return null; // Logic-only component
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ChevronLeft, Calendar, Award, Droplets, MapPin, Tag, ExternalLink, Package, Info, Loader2, WifiOff, CircleDollarSign, Wine, CheckCircle2, Circle, ChevronDown, Plus, Share2 } from 'lucide-react';
|
import { ChevronLeft, Calendar, Award, Droplets, MapPin, Tag, ExternalLink, Package, Info, Loader2, WifiOff, CircleDollarSign, Wine, CheckCircle2, Circle, ChevronDown, Plus, Share2, TrendingUp } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { updateBottle } from '@/services/update-bottle';
|
import { updateBottle } from '@/services/update-bottle';
|
||||||
import { getStorageUrl } from '@/lib/supabase';
|
import { getStorageUrl } from '@/lib/supabase';
|
||||||
@@ -12,6 +12,8 @@ import DeleteBottleButton from '@/components/DeleteBottleButton';
|
|||||||
import EditBottleForm from '@/components/EditBottleForm';
|
import EditBottleForm from '@/components/EditBottleForm';
|
||||||
import { useBottleData } from '@/hooks/useBottleData';
|
import { useBottleData } from '@/hooks/useBottleData';
|
||||||
import { useI18n } from '@/i18n/I18nContext';
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
|
import FlavorRadar from './FlavorRadar';
|
||||||
|
|
||||||
|
|
||||||
interface BottleDetailsProps {
|
interface BottleDetailsProps {
|
||||||
bottleId: string;
|
bottleId: string;
|
||||||
@@ -96,7 +98,7 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet
|
|||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto pb-24">
|
<div className="max-w-4xl mx-auto pb-24">
|
||||||
{/* Header / Hero Section */}
|
{/* Header / Hero Section */}
|
||||||
<div className="relative w-full overflow-hidden bg-[var(--surface)] shadow-2xl">
|
<div className="relative w-full overflow-hidden bg-(--surface) shadow-2xl">
|
||||||
{/* Back Button Overlay */}
|
{/* Back Button Overlay */}
|
||||||
<div className="absolute top-6 left-6 z-20">
|
<div className="absolute top-6 left-6 z-20">
|
||||||
<Link
|
<Link
|
||||||
@@ -108,9 +110,9 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hero Image - Slightly More Compact Aspect for better title flow */}
|
{/* Hero Image - Slightly More Compact Aspect for better title flow */}
|
||||||
<div className="relative aspect-[4/3] md:aspect-[16/8] w-full flex items-center justify-center p-6 md:p-10 overflow-hidden">
|
<div className="relative aspect-4/3 md:aspect-16/8 w-full flex items-center justify-center p-6 md:p-10 overflow-hidden">
|
||||||
{/* Background Glow */}
|
{/* Background Glow */}
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-orange-600/10 via-transparent to-transparent opacity-30" />
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,var(--tw-gradient-stops))] from-orange-600/10 via-transparent to-transparent opacity-30" />
|
||||||
<img
|
<img
|
||||||
src={getStorageUrl(bottle.image_url)}
|
src={getStorageUrl(bottle.image_url)}
|
||||||
alt={bottle.name}
|
alt={bottle.name}
|
||||||
@@ -119,7 +121,7 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info Overlay - Mobile Gradient */}
|
{/* Info Overlay - Mobile Gradient */}
|
||||||
<div className="absolute inset-x-0 bottom-0 h-48 bg-gradient-to-t from-[var(--background)] to-transparent pointer-events-none" />
|
<div className="absolute inset-x-0 bottom-0 h-48 bg-linear-to-t from-(--background) to-transparent pointer-events-none" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content Container */}
|
{/* Content Container */}
|
||||||
@@ -132,7 +134,7 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet
|
|||||||
<p className="text-[9px] font-black uppercase tracking-widest text-orange-500">Offline Mode</p>
|
<p className="text-[9px] font-black uppercase tracking-widest text-orange-500">Offline Mode</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<h2 className="text-xs md:text-sm font-black text-orange-500 uppercase tracking-[0.25em] drop-shadow-sm">
|
<h2 className="text-xs md:text-sm font-black text-orange-500 uppercase tracking-[0.25em] drop-shadow-xs">
|
||||||
{bottle.distillery || 'Unknown Distillery'}
|
{bottle.distillery || 'Unknown Distillery'}
|
||||||
</h2>
|
</h2>
|
||||||
<h1 className="text-4xl md:text-6xl font-extrabold text-white tracking-tight leading-[1.05] drop-shadow-md">
|
<h1 className="text-4xl md:text-6xl font-extrabold text-white tracking-tight leading-[1.05] drop-shadow-md">
|
||||||
@@ -167,6 +169,47 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet
|
|||||||
exit={{ opacity: 0, x: 20 }}
|
exit={{ opacity: 0, x: 20 }}
|
||||||
className="p-6 md:p-8 space-y-8"
|
className="p-6 md:p-8 space-y-8"
|
||||||
>
|
>
|
||||||
|
{/* Flavor Profile Section */}
|
||||||
|
{tastings && tastings.some((t: any) => t.flavor_profile) && (
|
||||||
|
<div className="bg-black/20 rounded-3xl border border-white/5 p-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-2 px-1">
|
||||||
|
<TrendingUp size={14} className="text-orange-500" />
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-widest text-zinc-500">Average Flavor Profile</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col md:flex-row items-center gap-6">
|
||||||
|
<div className="w-full md:w-1/2">
|
||||||
|
<FlavorRadar
|
||||||
|
profile={(() => {
|
||||||
|
const validProfiles = tastings.filter((t: any) => t.flavor_profile).map((t: any) => t.flavor_profile);
|
||||||
|
const count = validProfiles.length;
|
||||||
|
return {
|
||||||
|
smoky: Math.round(validProfiles.reduce((s, p) => s + p.smoky, 0) / count),
|
||||||
|
fruity: Math.round(validProfiles.reduce((s, p) => s + p.fruity, 0) / count),
|
||||||
|
spicy: Math.round(validProfiles.reduce((s, p) => s + p.spicy, 0) / count),
|
||||||
|
sweet: Math.round(validProfiles.reduce((s, p) => s + p.sweet, 0) / count),
|
||||||
|
floral: Math.round(validProfiles.reduce((s, p) => s + p.floral, 0) / count),
|
||||||
|
};
|
||||||
|
})()}
|
||||||
|
size={220}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-full md:w-1/2 space-y-2">
|
||||||
|
<p className="text-xs text-zinc-400 leading-relaxed font-medium italic">
|
||||||
|
Basierend auf {tastings.filter((t: any) => t.flavor_profile).length} Verkostungen. Dieses Diagramm zeigt den durchschnittlichen Charakter dieser Flasche.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2 pt-2">
|
||||||
|
{['smoky', 'fruity', 'spicy', 'sweet', 'floral'].map(attr => (
|
||||||
|
<div key={attr} className="flex items-center gap-2">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-orange-600" />
|
||||||
|
<span className="text-[9px] font-black uppercase tracking-wider text-zinc-500">{attr}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Fact Grid - Integrated Metadata & Stats */}
|
{/* Fact Grid - Integrated Metadata & Stats */}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
<FactCard label="Category" value={bottle.category || 'Whisky'} icon={<Wine size={14} />} />
|
<FactCard label="Category" value={bottle.category || 'Whisky'} icon={<Wine size={14} />} />
|
||||||
|
|||||||
@@ -32,61 +32,46 @@ interface BottleCardProps {
|
|||||||
|
|
||||||
function BottleCard({ bottle, sessionId }: BottleCardProps) {
|
function BottleCard({ bottle, sessionId }: BottleCardProps) {
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
|
const imageUrl = getStorageUrl(bottle.image_url);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/bottles/${bottle.id}${sessionId ? `?session_id=${sessionId}` : ''}`}
|
href={`/bottles/${bottle.id}${sessionId ? `?session_id=${sessionId}` : ''}`}
|
||||||
className="block h-full group relative overflow-hidden rounded-2xl bg-zinc-800/20 backdrop-blur-sm border border-white/[0.05] transition-all duration-500 hover:border-orange-500/30 hover:shadow-2xl hover:shadow-orange-950/20 active:scale-[0.98] flex flex-col"
|
className="block h-full group relative overflow-hidden rounded-2xl bg-zinc-900 border border-white/5 transition-all duration-500 hover:border-orange-500/30 hover:shadow-2xl hover:shadow-orange-950/20 active:scale-[0.98]"
|
||||||
>
|
>
|
||||||
{/* Image Layer - Clean Split Top */}
|
{/* === SPOTIFY-STYLE IMAGE SECTION === */}
|
||||||
<div className="aspect-[4/3] overflow-hidden shrink-0">
|
<div className="relative aspect-3/4 overflow-hidden">
|
||||||
|
|
||||||
|
{/* Layer 1: Blurred Backdrop */}
|
||||||
|
<div className="absolute inset-0 z-0">
|
||||||
<img
|
<img
|
||||||
src={getStorageUrl(bottle.image_url)}
|
src={imageUrl}
|
||||||
alt={bottle.name}
|
alt=""
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500 ease-out"
|
loading="lazy"
|
||||||
|
className="w-full h-full object-cover scale-125 blur-[20px] saturate-150 brightness-[0.6]"
|
||||||
|
/>
|
||||||
|
{/* Vignette Overlay */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
background: 'radial-gradient(circle, rgba(0,0,0,0) 20%, rgba(0,0,0,0.5) 80%)'
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info Layer - Clean Split Bottom */}
|
{/* Layer 2: Sharp Foreground Image */}
|
||||||
<div className="p-4 flex-1 flex flex-col justify-between space-y-4">
|
<div className="absolute inset-[10px] z-10 flex items-center justify-center">
|
||||||
<div className="space-y-1">
|
<img
|
||||||
<p className="text-[10px] font-black text-orange-600 uppercase tracking-[0.2em] leading-none mb-1">
|
src={imageUrl}
|
||||||
{bottle.distillery}
|
alt={bottle.name}
|
||||||
</p>
|
loading="lazy"
|
||||||
<h3 className="font-bold text-lg text-zinc-50 leading-tight tracking-tight">
|
className="max-w-full max-h-full object-contain drop-shadow-[0_10px_20px_rgba(0,0,0,0.5)] group-hover:scale-105 transition-transform duration-500 ease-out"
|
||||||
{bottle.name || t('grid.unknownBottle')}
|
/>
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4 pt-2">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<span className="px-2 py-1 bg-zinc-800 text-zinc-400 text-[9px] font-bold uppercase tracking-widest rounded-md">
|
|
||||||
{shortenCategory(bottle.category)}
|
|
||||||
</span>
|
|
||||||
<span className="px-2 py-1 bg-zinc-800 text-zinc-400 text-[9px] font-bold uppercase tracking-widest rounded-md">
|
|
||||||
{bottle.abv}% VOL
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Metadata items */}
|
|
||||||
<div className="flex items-center gap-4 pt-3 border-t border-zinc-800/50 mt-auto">
|
|
||||||
<div className="flex items-center gap-1 text-[10px] font-medium text-zinc-500">
|
|
||||||
<Calendar size={12} className="text-zinc-500" />
|
|
||||||
{new Date(bottle.created_at).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')}
|
|
||||||
</div>
|
|
||||||
{bottle.last_tasted && (
|
|
||||||
<div className="flex items-center gap-1 text-[10px] font-medium text-zinc-500">
|
|
||||||
<Clock size={12} className="text-zinc-500" />
|
|
||||||
{new Date(bottle.last_tasted).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top Overlays */}
|
{/* Top Overlays */}
|
||||||
{(bottle.is_whisky === false || (bottle.confidence && bottle.confidence < 70)) && (
|
{(bottle.is_whisky === false || (bottle.confidence && bottle.confidence < 70)) && (
|
||||||
<div className="absolute top-3 right-3 z-10">
|
<div className="absolute top-3 right-3 z-20">
|
||||||
<div className="bg-red-500 text-white p-1.5 rounded-full shadow-lg">
|
<div className="bg-red-500 text-white p-1.5 rounded-full shadow-lg">
|
||||||
<AlertCircle size={12} />
|
<AlertCircle size={12} />
|
||||||
</div>
|
</div>
|
||||||
@@ -94,15 +79,44 @@ function BottleCard({ bottle, sessionId }: BottleCardProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{sessionId && (
|
{sessionId && (
|
||||||
<div className="absolute top-3 left-3 z-10 bg-orange-600 text-white text-[9px] font-bold px-2 py-1 rounded-md flex items-center gap-1.5 shadow-xl">
|
<div className="absolute top-3 left-3 z-20 bg-orange-600 text-white text-[9px] font-bold px-2 py-1 rounded-md flex items-center gap-1.5 shadow-xl">
|
||||||
<PlusCircle size={12} />
|
<PlusCircle size={12} />
|
||||||
ADD
|
ADD
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Bottom Gradient Overlay for Text */}
|
||||||
|
<div
|
||||||
|
className="absolute bottom-0 left-0 right-0 z-10 h-32"
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(to top, rgba(0,0,0,0.9) 0%, transparent 100%)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Info Overlay at Bottom */}
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 z-20 p-4 text-white">
|
||||||
|
<p className="text-[10px] font-black text-orange-500 uppercase tracking-[0.2em] leading-none mb-1">
|
||||||
|
{bottle.distillery}
|
||||||
|
</p>
|
||||||
|
<h3 className="font-bold text-lg text-zinc-50 leading-tight tracking-tight line-clamp-2">
|
||||||
|
{bottle.name || t('grid.unknownBottle')}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 mt-3">
|
||||||
|
<span className="px-2 py-1 bg-white/10 backdrop-blur-xs text-zinc-300 text-[9px] font-bold uppercase tracking-widest rounded-md">
|
||||||
|
{shortenCategory(bottle.category)}
|
||||||
|
</span>
|
||||||
|
<span className="px-2 py-1 bg-white/10 backdrop-blur-xs text-zinc-300 text-[9px] font-bold uppercase tracking-widest rounded-md">
|
||||||
|
{bottle.abv}% VOL
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
interface BottleGridProps {
|
interface BottleGridProps {
|
||||||
bottles: any[];
|
bottles: any[];
|
||||||
}
|
}
|
||||||
@@ -202,7 +216,7 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
|
|||||||
placeholder={t('grid.searchPlaceholder')}
|
placeholder={t('grid.searchPlaceholder')}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="w-full pl-8 pr-8 py-4 bg-transparent border-b border-zinc-800 focus:border-orange-500 outline-none transition-all text-zinc-50 placeholder:text-zinc-500"
|
className="w-full pl-8 pr-8 py-4 bg-transparent border-b border-zinc-800 focus:border-orange-500 outline-hidden transition-all text-zinc-50 placeholder:text-zinc-500"
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
@@ -218,7 +232,7 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
|
|||||||
<select
|
<select
|
||||||
value={sortBy}
|
value={sortBy}
|
||||||
onChange={(e) => setSortBy(e.target.value as any)}
|
onChange={(e) => setSortBy(e.target.value as any)}
|
||||||
className="bg-transparent border-none text-zinc-500 text-xs font-bold uppercase tracking-widest outline-none cursor-pointer hover:text-white transition-colors appearance-none"
|
className="bg-transparent border-none text-zinc-500 text-xs font-bold uppercase tracking-widest outline-hidden cursor-pointer hover:text-white transition-colors appearance-none"
|
||||||
>
|
>
|
||||||
<option value="created_at" className="bg-zinc-950">{t('grid.sortBy.createdAt')}</option>
|
<option value="created_at" className="bg-zinc-950">{t('grid.sortBy.createdAt')}</option>
|
||||||
<option value="last_tasted" className="bg-zinc-950">{t('grid.sortBy.lastTasted')}</option>
|
<option value="last_tasted" className="bg-zinc-950">{t('grid.sortBy.lastTasted')}</option>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export default function BottleSkeletonCard({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Image */}
|
{/* Image */}
|
||||||
<div className="aspect-[3/4] bg-zinc-950 relative overflow-hidden">
|
<div className="aspect-3/4 bg-zinc-950 relative overflow-hidden">
|
||||||
{imageUrl ? (
|
{imageUrl ? (
|
||||||
<img
|
<img
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
@@ -81,12 +81,12 @@ export default function BottleSkeletonCard({
|
|||||||
{/* Info */}
|
{/* Info */}
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
{/* Skeleton Name */}
|
{/* Skeleton Name */}
|
||||||
<div className="h-4 bg-zinc-800 rounded animate-pulse mb-2 w-3/4" />
|
<div className="h-4 bg-zinc-800 rounded-sm animate-pulse mb-2 w-3/4" />
|
||||||
|
|
||||||
{/* Skeleton Details */}
|
{/* Skeleton Details */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="h-3 bg-zinc-800/50 rounded animate-pulse w-12" />
|
<div className="h-3 bg-zinc-800/50 rounded-sm animate-pulse w-12" />
|
||||||
<div className="h-3 bg-zinc-800/50 rounded animate-pulse w-8" />
|
<div className="h-3 bg-zinc-800/50 rounded-sm animate-pulse w-8" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export default function BuddyHandshake({ isOpen, onClose, onSuccess }: BuddyHand
|
|||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
className="fixed inset-0 bg-black/80 backdrop-blur-xs z-50 flex items-center justify-center p-4"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -240,7 +240,7 @@ export default function BuddyHandshake({ isOpen, onClose, onSuccess }: BuddyHand
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="XXXXXX"
|
placeholder="XXXXXX"
|
||||||
className="w-full text-center text-3xl font-black tracking-[0.4em] bg-zinc-950 border-2 border-zinc-800 rounded-2xl py-4 text-white placeholder:text-zinc-700 focus:outline-none focus:border-orange-500 transition-colors font-mono"
|
className="w-full text-center text-3xl font-black tracking-[0.4em] bg-zinc-950 border-2 border-zinc-800 rounded-2xl py-4 text-white placeholder:text-zinc-700 focus:outline-hidden focus:border-orange-500 transition-colors font-mono"
|
||||||
maxLength={6}
|
maxLength={6}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export default function BuddyList() {
|
|||||||
value={newName}
|
value={newName}
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
placeholder={t('buddy.placeholder')}
|
placeholder={t('buddy.placeholder')}
|
||||||
className="flex-1 bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2 text-sm text-zinc-50 placeholder:text-zinc-600 focus:outline-none focus:border-orange-600 transition-colors"
|
className="flex-1 bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2 text-sm text-zinc-50 placeholder:text-zinc-600 focus:outline-hidden focus:border-orange-600 transition-colors"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -183,12 +183,12 @@ export default function BuddyList() {
|
|||||||
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-top-1">
|
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-top-1">
|
||||||
<div className="flex -space-x-1.5 overflow-hidden">
|
<div className="flex -space-x-1.5 overflow-hidden">
|
||||||
{buddies.slice(0, 5).map((b, i) => (
|
{buddies.slice(0, 5).map((b, i) => (
|
||||||
<div key={b.id} className="w-7 h-7 rounded-lg bg-zinc-950 border border-zinc-800 flex items-center justify-center text-[10px] font-bold text-orange-600 shadow-sm">
|
<div key={b.id} className="w-7 h-7 rounded-lg bg-zinc-950 border border-zinc-800 flex items-center justify-center text-[10px] font-bold text-orange-600 shadow-xs">
|
||||||
{b.name[0].toUpperCase()}
|
{b.name[0].toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{buddies.length > 5 && (
|
{buddies.length > 5 && (
|
||||||
<div className="w-7 h-7 rounded-lg bg-zinc-950 border border-zinc-800 flex items-center justify-center text-[8px] font-bold text-zinc-500 shadow-sm">
|
<div className="w-7 h-7 rounded-lg bg-zinc-950 border border-zinc-800 flex items-center justify-center text-[8px] font-bold text-zinc-500 shadow-xs">
|
||||||
+{buddies.length - 5}
|
+{buddies.length - 5}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export default function BulkScanSheet({
|
|||||||
className="fixed inset-0 bg-black z-50 flex flex-col"
|
className="fixed inset-0 bg-black z-50 flex flex-col"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-4 bg-zinc-900/80 backdrop-blur-sm border-b border-zinc-800">
|
<div className="flex items-center justify-between p-4 bg-zinc-900/80 backdrop-blur-xs border-b border-zinc-800">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-xl bg-orange-600/20 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-xl bg-orange-600/20 flex items-center justify-center">
|
||||||
<Zap size={20} className="text-orange-500" />
|
<Zap size={20} className="text-orange-500" />
|
||||||
@@ -240,7 +240,7 @@ export default function BulkScanSheet({
|
|||||||
<X size={12} className="text-white" />
|
<X size={12} className="text-white" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<span className="absolute bottom-0.5 left-0.5 text-[9px] font-bold text-white bg-black/60 px-1 rounded">
|
<span className="absolute bottom-0.5 left-0.5 text-[9px] font-bold text-white bg-black/60 px-1 rounded-sm">
|
||||||
#{i + 1}
|
#{i + 1}
|
||||||
</span>
|
</span>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import { shortenCategory } from '@/lib/format';
|
|||||||
import { scanLabel } from '@/app/actions/scanner';
|
import { scanLabel } from '@/app/actions/scanner';
|
||||||
import { enrichData } from '@/app/actions/enrich-data';
|
import { enrichData } from '@/app/actions/enrich-data';
|
||||||
import { processImageForAI } from '@/utils/image-processing';
|
import { processImageForAI } from '@/utils/image-processing';
|
||||||
|
import { runCascadeOCR } from '@/services/cascade-ocr';
|
||||||
|
import { FEATURES } from '@/config/features';
|
||||||
|
|
||||||
interface CameraCaptureProps {
|
interface CameraCaptureProps {
|
||||||
onImageCaptured?: (base64Image: string) => void;
|
onImageCaptured?: (base64Image: string) => void;
|
||||||
@@ -64,7 +66,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
|||||||
const [isDiscovering, setIsDiscovering] = useState(false);
|
const [isDiscovering, setIsDiscovering] = useState(false);
|
||||||
const [originalFile, setOriginalFile] = useState<File | null>(null);
|
const [originalFile, setOriginalFile] = useState<File | null>(null);
|
||||||
const [isAdmin, setIsAdmin] = useState(false);
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
const [aiProvider, setAiProvider] = useState<'gemini' | 'mistral'>('gemini');
|
const [aiProvider, setAiProvider] = useState<'gemini' | 'openrouter'>('gemini');
|
||||||
|
|
||||||
const [perfMetrics, setPerfMetrics] = useState<{
|
const [perfMetrics, setPerfMetrics] = useState<{
|
||||||
compression: number;
|
compression: number;
|
||||||
@@ -159,6 +161,13 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', processed.file);
|
formData.append('file', processed.file);
|
||||||
|
|
||||||
|
// Run Cascade OCR in parallel (for comparison/logging only - doesn't block AI)
|
||||||
|
if (FEATURES.ENABLE_CASCADE_OCR) {
|
||||||
|
runCascadeOCR(processed.file).catch(err => {
|
||||||
|
console.warn('[CameraCapture] Cascade OCR failed:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const startAi = performance.now();
|
const startAi = performance.now();
|
||||||
const response = await scanLabel(formData);
|
const response = await scanLabel(formData);
|
||||||
const endAi = performance.now();
|
const endAi = performance.now();
|
||||||
@@ -298,10 +307,10 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
|||||||
Gemini
|
Gemini
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setAiProvider('mistral')}
|
onClick={() => setAiProvider('openrouter')}
|
||||||
className={`px-3 py-1 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all ${aiProvider === 'mistral' ? 'bg-orange-600 text-white shadow-xl shadow-orange-950/20' : 'text-zinc-500 hover:text-zinc-300'}`}
|
className={`px-3 py-1 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all ${aiProvider === 'openrouter' ? 'bg-orange-600 text-white shadow-xl shadow-orange-950/20' : 'text-zinc-500 hover:text-zinc-300'}`}
|
||||||
>
|
>
|
||||||
Mistral 3 🇪🇺
|
Gemma 🇪🇺
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -464,7 +473,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isQueued && (
|
{isQueued && (
|
||||||
<div className="flex flex-col gap-3 p-5 bg-gradient-to-br from-purple-500/10 to-amber-500/10 dark:from-purple-900/20 dark:to-amber-900/20 border border-purple-500/20 rounded-3xl w-full animate-in zoom-in-95 duration-500">
|
<div className="flex flex-col gap-3 p-5 bg-linear-to-br from-purple-500/10 to-amber-500/10 dark:from-purple-900/20 dark:to-amber-900/20 border border-purple-500/20 rounded-3xl w-full animate-in zoom-in-95 duration-500">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-2xl bg-purple-500 flex items-center justify-center text-white shadow-lg shadow-purple-500/30"><Sparkles size={20} /></div>
|
<div className="w-10 h-10 rounded-2xl bg-purple-500 flex items-center justify-center text-white shadow-lg shadow-purple-500/30"><Sparkles size={20} /></div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export default function CookieBanner() {
|
|||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
exit={{ y: 100, opacity: 0 }}
|
exit={{ y: 100, opacity: 0 }}
|
||||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||||
className="fixed bottom-0 left-0 right-0 z-[100] p-4 md:p-6"
|
className="fixed bottom-0 left-0 right-0 z-100 p-4 md:p-6"
|
||||||
>
|
>
|
||||||
<div className="max-w-4xl mx-auto bg-zinc-900 border border-zinc-800 rounded-2xl shadow-2xl overflow-hidden">
|
<div className="max-w-4xl mx-auto bg-zinc-900 border border-zinc-800 rounded-2xl shadow-2xl overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export default function DramOfTheDay({ bottles }: DramOfTheDayProps) {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{suggestion && (
|
{suggestion && (
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-6 bg-zinc-950/80 backdrop-blur-sm animate-in fade-in duration-300">
|
<div className="fixed inset-0 z-100 flex items-center justify-center p-6 bg-zinc-950/80 backdrop-blur-xs animate-in fade-in duration-300">
|
||||||
<div className="bg-zinc-900 w-full max-w-sm rounded-[40px] p-8 shadow-2xl border border-orange-500/20 relative animate-in zoom-in-95 duration-300">
|
<div className="bg-zinc-900 w-full max-w-sm rounded-[40px] p-8 shadow-2xl border border-orange-500/20 relative animate-in zoom-in-95 duration-300">
|
||||||
<button
|
<button
|
||||||
onClick={() => setSuggestion(null)}
|
onClick={() => setSuggestion(null)}
|
||||||
|
|||||||
@@ -36,10 +36,10 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
|||||||
name: bottle.name,
|
name: bottle.name,
|
||||||
distillery: bottle.distillery || '',
|
distillery: bottle.distillery || '',
|
||||||
category: bottle.category || '',
|
category: bottle.category || '',
|
||||||
abv: bottle.abv || 0,
|
abv: bottle.abv?.toString() || '',
|
||||||
age: bottle.age || 0,
|
age: bottle.age?.toString() || '',
|
||||||
whiskybase_id: bottle.whiskybase_id || '',
|
whiskybase_id: bottle.whiskybase_id || '',
|
||||||
purchase_price: bottle.purchase_price || '',
|
purchase_price: bottle.purchase_price?.toString() || '',
|
||||||
distilled_at: bottle.distilled_at || '',
|
distilled_at: bottle.distilled_at || '',
|
||||||
bottled_at: bottle.bottled_at || '',
|
bottled_at: bottle.bottled_at || '',
|
||||||
batch_info: bottle.batch_info || '',
|
batch_info: bottle.batch_info || '',
|
||||||
@@ -54,8 +54,8 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
|||||||
const result = await discoverWhiskybaseId({
|
const result = await discoverWhiskybaseId({
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
distillery: formData.distillery,
|
distillery: formData.distillery,
|
||||||
abv: formData.abv,
|
abv: formData.abv ? parseFloat(formData.abv) : undefined,
|
||||||
age: formData.age,
|
age: formData.age ? parseInt(formData.age) : undefined,
|
||||||
distilled_at: formData.distilled_at || undefined,
|
distilled_at: formData.distilled_at || undefined,
|
||||||
bottled_at: formData.bottled_at || undefined,
|
bottled_at: formData.bottled_at || undefined,
|
||||||
batch_info: formData.batch_info || undefined,
|
batch_info: formData.batch_info || undefined,
|
||||||
@@ -83,14 +83,14 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
|||||||
try {
|
try {
|
||||||
const response = await updateBottle(bottle.id, {
|
const response = await updateBottle(bottle.id, {
|
||||||
...formData,
|
...formData,
|
||||||
abv: Number(formData.abv),
|
abv: formData.abv ? parseFloat(formData.abv.replace(',', '.')) : null,
|
||||||
age: formData.age ? Number(formData.age) : undefined,
|
age: formData.age ? parseInt(formData.age) : null,
|
||||||
purchase_price: formData.purchase_price ? Number(formData.purchase_price) : undefined,
|
purchase_price: formData.purchase_price ? parseFloat(formData.purchase_price.replace(',', '.')) : null,
|
||||||
distilled_at: formData.distilled_at || undefined,
|
distilled_at: formData.distilled_at || undefined,
|
||||||
bottled_at: formData.bottled_at || undefined,
|
bottled_at: formData.bottled_at || undefined,
|
||||||
batch_info: formData.batch_info || undefined,
|
batch_info: formData.batch_info || undefined,
|
||||||
cask_type: formData.cask_type || undefined,
|
cask_type: formData.cask_type || undefined,
|
||||||
});
|
} as any);
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
@@ -115,7 +115,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
|||||||
type="text"
|
type="text"
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
|
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-hidden focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
|||||||
type="text"
|
type="text"
|
||||||
value={formData.distillery}
|
value={formData.distillery}
|
||||||
onChange={(e) => setFormData({ ...formData, distillery: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, distillery: e.target.value })}
|
||||||
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
|
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-hidden focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
|||||||
type="text"
|
type="text"
|
||||||
value={formData.category}
|
value={formData.category}
|
||||||
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
||||||
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
|
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-hidden focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -145,22 +145,23 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.abvLabel')}</label>
|
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.abvLabel')}</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="text"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
step="0.1"
|
|
||||||
value={formData.abv}
|
value={formData.abv}
|
||||||
onChange={(e) => setFormData({ ...formData, abv: parseFloat(e.target.value) })}
|
onChange={(e) => setFormData({ ...formData, abv: e.target.value })}
|
||||||
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-orange-500 text-sm font-bold transition-all"
|
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-hidden focus:ring-2 focus:ring-orange-600/50 text-orange-500 text-sm font-bold transition-all"
|
||||||
|
placeholder="e.g. 46.3"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.ageLabel')}</label>
|
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.ageLabel')}</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={formData.age}
|
value={formData.age}
|
||||||
onChange={(e) => setFormData({ ...formData, age: parseInt(e.target.value) })}
|
onChange={(e) => setFormData({ ...formData, age: e.target.value })}
|
||||||
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all"
|
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-hidden focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all"
|
||||||
|
placeholder="e.g. 12"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,7 +176,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
|||||||
placeholder="YYYY"
|
placeholder="YYYY"
|
||||||
value={formData.distilled_at}
|
value={formData.distilled_at}
|
||||||
onChange={(e) => setFormData({ ...formData, distilled_at: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, distilled_at: e.target.value })}
|
||||||
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
|
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-hidden focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -186,7 +187,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
|||||||
placeholder="YYYY"
|
placeholder="YYYY"
|
||||||
value={formData.bottled_at}
|
value={formData.bottled_at}
|
||||||
onChange={(e) => setFormData({ ...formData, bottled_at: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, bottled_at: e.target.value })}
|
||||||
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
|
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-hidden focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -196,13 +197,12 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.priceLabel')} (€)</label>
|
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.priceLabel')} (€)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="text"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
step="0.01"
|
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
value={formData.purchase_price}
|
value={formData.purchase_price}
|
||||||
onChange={(e) => setFormData({ ...formData, purchase_price: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, purchase_price: e.target.value })}
|
||||||
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 font-bold text-orange-500 text-sm transition-all"
|
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-hidden focus:ring-2 focus:ring-orange-600/50 font-bold text-orange-500 text-sm transition-all"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -225,7 +225,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
|||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={formData.whiskybase_id}
|
value={formData.whiskybase_id}
|
||||||
onChange={(e) => setFormData({ ...formData, whiskybase_id: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, whiskybase_id: e.target.value })}
|
||||||
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-300 text-sm font-mono transition-all"
|
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-hidden focus:ring-2 focus:ring-orange-600/50 text-zinc-300 text-sm font-mono transition-all"
|
||||||
/>
|
/>
|
||||||
{discoveryResult && (
|
{discoveryResult && (
|
||||||
<div className="absolute top-full left-0 right-0 z-50 mt-3 p-4 bg-zinc-900 border border-orange-500/30 rounded-2xl shadow-2xl animate-in zoom-in-95 duration-300">
|
<div className="absolute top-full left-0 right-0 z-50 mt-3 p-4 bg-zinc-900 border border-orange-500/30 rounded-2xl shadow-2xl animate-in zoom-in-95 duration-300">
|
||||||
@@ -263,7 +263,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
|||||||
placeholder="e.g. Batch 12 or L-Code"
|
placeholder="e.g. Batch 12 or L-Code"
|
||||||
value={formData.batch_info}
|
value={formData.batch_info}
|
||||||
onChange={(e) => setFormData({ ...formData, batch_info: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, batch_info: e.target.value })}
|
||||||
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
|
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-hidden focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -273,7 +273,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
|||||||
placeholder="e.g. Oloroso Sherry"
|
placeholder="e.g. Oloroso Sherry"
|
||||||
value={formData.cask_type}
|
value={formData.cask_type}
|
||||||
onChange={(e) => setFormData({ ...formData, cask_type: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, cask_type: e.target.value })}
|
||||||
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
|
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-hidden focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -296,7 +296,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
|||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
className="flex-[2] py-4 bg-orange-600 hover:bg-orange-700 text-white rounded-2xl font-black uppercase tracking-widest text-xs transition-all flex items-center justify-center gap-2 shadow-xl shadow-orange-950/20 disabled:opacity-50"
|
className="flex-2 py-4 bg-orange-600 hover:bg-orange-700 text-white rounded-2xl font-black uppercase tracking-widest text-xs transition-all flex items-center justify-center gap-2 shadow-xl shadow-orange-950/20 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isSaving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
{isSaving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
||||||
{t('bottle.saveChanges')}
|
{t('bottle.saveChanges')}
|
||||||
|
|||||||
65
src/components/FlavorRadar.tsx
Normal file
65
src/components/FlavorRadar.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Radar,
|
||||||
|
RadarChart,
|
||||||
|
PolarGrid,
|
||||||
|
PolarAngleAxis,
|
||||||
|
PolarRadiusAxis,
|
||||||
|
ResponsiveContainer
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
interface FlavorProfile {
|
||||||
|
smoky: number;
|
||||||
|
fruity: number;
|
||||||
|
spicy: number;
|
||||||
|
sweet: number;
|
||||||
|
floral: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FlavorRadarProps {
|
||||||
|
profile: FlavorProfile;
|
||||||
|
size?: number;
|
||||||
|
showAxis?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FlavorRadar({ profile, size = 300, showAxis = true }: FlavorRadarProps) {
|
||||||
|
const data = [
|
||||||
|
{ subject: 'Smoky', A: profile.smoky, fullMark: 100 },
|
||||||
|
{ subject: 'Fruity', A: profile.fruity, fullMark: 100 },
|
||||||
|
{ subject: 'Spicy', A: profile.spicy, fullMark: 100 },
|
||||||
|
{ subject: 'Sweet', A: profile.sweet, fullMark: 100 },
|
||||||
|
{ subject: 'Floral', A: profile.floral, fullMark: 100 },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: '100%', height: size }} className="flex items-center justify-center">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<RadarChart cx="50%" cy="50%" outerRadius="70%" data={data}>
|
||||||
|
<PolarGrid stroke="#3f3f46" />
|
||||||
|
<PolarAngleAxis
|
||||||
|
dataKey="subject"
|
||||||
|
tick={{ fill: '#71717a', fontSize: 10, fontWeight: 700 }}
|
||||||
|
/>
|
||||||
|
{!showAxis && <PolarRadiusAxis axisLine={false} tick={false} />}
|
||||||
|
{showAxis && (
|
||||||
|
<PolarRadiusAxis
|
||||||
|
angle={30}
|
||||||
|
domain={[0, 100]}
|
||||||
|
tick={{ fill: '#3f3f46', fontSize: 8 }}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Radar
|
||||||
|
name="Flavor"
|
||||||
|
dataKey="A"
|
||||||
|
stroke="#d97706"
|
||||||
|
fill="#d97706"
|
||||||
|
fillOpacity={0.5}
|
||||||
|
/>
|
||||||
|
</RadarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -108,7 +108,7 @@ export default function FloatingScannerButton({ onImageSelected }: FloatingScann
|
|||||||
ease: "easeInOut",
|
ease: "easeInOut",
|
||||||
repeatDelay: 3
|
repeatDelay: 3
|
||||||
}}
|
}}
|
||||||
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/40 to-transparent skew-x-12 -z-0"
|
className="absolute inset-0 bg-linear-to-r from-transparent via-white/40 to-transparent skew-x-12 z-0"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
85
src/components/HeroBanner.tsx
Normal file
85
src/components/HeroBanner.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Banner {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
image_url: string;
|
||||||
|
link_target: string | null;
|
||||||
|
cta_text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HeroBanner() {
|
||||||
|
const [banner, setBanner] = useState<Banner | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchBanner = async () => {
|
||||||
|
try {
|
||||||
|
const supabase = createClient();
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('app_banners')
|
||||||
|
.select('*')
|
||||||
|
.eq('is_active', true)
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (!error && data) {
|
||||||
|
setBanner(data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[HeroBanner] Failed to fetch:', err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchBanner();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Don't render if no active banner
|
||||||
|
if (isLoading || !banner) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div
|
||||||
|
className="relative h-48 rounded-2xl overflow-hidden bg-zinc-900 group"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${banner.image_url})`,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Overlay gradient */}
|
||||||
|
<div className="absolute inset-0 bg-linear-to-t from-black/80 via-black/20 to-transparent" />
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 p-4">
|
||||||
|
<h3 className="text-lg font-bold text-white mb-1 line-clamp-2">
|
||||||
|
{banner.title}
|
||||||
|
</h3>
|
||||||
|
{banner.link_target && (
|
||||||
|
<div className="flex items-center gap-1 text-orange-500 text-xs font-bold uppercase tracking-wider">
|
||||||
|
{banner.cta_text}
|
||||||
|
<ChevronRight size={14} className="group-hover:translate-x-1 transition-transform" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (banner.link_target) {
|
||||||
|
return (
|
||||||
|
<Link href={banner.link_target} className="block">
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ const LanguageSwitcher = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setLocale('de')}
|
onClick={() => setLocale('de')}
|
||||||
className={`p-1.5 rounded-lg transition-all ${locale === 'de'
|
className={`p-1.5 rounded-lg transition-all ${locale === 'de'
|
||||||
? 'bg-orange-950/30 scale-110 shadow-sm shadow-orange-950/20'
|
? 'bg-orange-950/30 scale-110 shadow-xs shadow-orange-950/20'
|
||||||
: 'opacity-50 hover:opacity-100 grayscale hover:grayscale-0'
|
: 'opacity-50 hover:opacity-100 grayscale hover:grayscale-0'
|
||||||
}`}
|
}`}
|
||||||
title="Deutsch"
|
title="Deutsch"
|
||||||
@@ -21,7 +21,7 @@ const LanguageSwitcher = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setLocale('en')}
|
onClick={() => setLocale('en')}
|
||||||
className={`p-1.5 rounded-lg transition-all ${locale === 'en'
|
className={`p-1.5 rounded-lg transition-all ${locale === 'en'
|
||||||
? 'bg-orange-950/30 scale-110 shadow-sm shadow-orange-950/20'
|
? 'bg-orange-950/30 scale-110 shadow-xs shadow-orange-950/20'
|
||||||
: 'opacity-50 hover:opacity-100 grayscale hover:grayscale-0'
|
: 'opacity-50 hover:opacity-100 grayscale hover:grayscale-0'
|
||||||
}`}
|
}`}
|
||||||
title="English"
|
title="English"
|
||||||
|
|||||||
277
src/components/NativeOCRScanner.tsx
Normal file
277
src/components/NativeOCRScanner.tsx
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native OCR Scanner Component
|
||||||
|
*
|
||||||
|
* Uses the Shape Detection API (TextDetector) for zero-latency,
|
||||||
|
* zero-download OCR directly from the camera stream.
|
||||||
|
*
|
||||||
|
* Only works on Android/Chrome/Edge. iOS uses the Live Text fallback.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||||
|
import { X, Camera, Loader2, Zap, CheckCircle } from 'lucide-react';
|
||||||
|
import { useScanFlow } from '@/hooks/useScanFlow';
|
||||||
|
import { normalizeDistillery } from '@/lib/distillery-matcher';
|
||||||
|
|
||||||
|
interface NativeOCRScannerProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onTextDetected: (texts: string[]) => void;
|
||||||
|
onAutoCapture?: (result: {
|
||||||
|
rawTexts: string[];
|
||||||
|
distillery: string | null;
|
||||||
|
abv: number | null;
|
||||||
|
age: number | null;
|
||||||
|
}) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegEx patterns for auto-extraction
|
||||||
|
const PATTERNS = {
|
||||||
|
abv: /(\d{1,2}[.,]\d{1}|\d{1,2})\s*%\s*(?:vol|alc)?/i,
|
||||||
|
age: /(\d{1,2})\s*(?:years?|yo|y\.?o\.?|jahre?)\s*(?:old)?/i,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NativeOCRScanner({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onTextDetected,
|
||||||
|
onAutoCapture
|
||||||
|
}: NativeOCRScannerProps) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
|
const animationRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const { processVideoFrame } = useScanFlow();
|
||||||
|
|
||||||
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
|
const [detectedTexts, setDetectedTexts] = useState<string[]>([]);
|
||||||
|
const [extractedData, setExtractedData] = useState<{
|
||||||
|
distillery: string | null;
|
||||||
|
abv: number | null;
|
||||||
|
age: number | null;
|
||||||
|
}>({ distillery: null, abv: null, age: null });
|
||||||
|
const [isAutoCapturing, setIsAutoCapturing] = useState(false);
|
||||||
|
|
||||||
|
// Start camera stream
|
||||||
|
const startStream = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
console.log('[NativeOCR] Starting camera stream...');
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: {
|
||||||
|
facingMode: 'environment',
|
||||||
|
width: { ideal: 1280 },
|
||||||
|
height: { ideal: 720 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
streamRef.current = stream;
|
||||||
|
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.srcObject = stream;
|
||||||
|
await videoRef.current.play();
|
||||||
|
setIsStreaming(true);
|
||||||
|
console.log('[NativeOCR] Camera stream started');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[NativeOCR] Camera access failed:', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Stop camera stream
|
||||||
|
const stopStream = useCallback(() => {
|
||||||
|
console.log('[NativeOCR] Stopping camera stream...');
|
||||||
|
|
||||||
|
if (animationRef.current) {
|
||||||
|
cancelAnimationFrame(animationRef.current);
|
||||||
|
animationRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (streamRef.current) {
|
||||||
|
streamRef.current.getTracks().forEach(track => track.stop());
|
||||||
|
streamRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.srcObject = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsStreaming(false);
|
||||||
|
setDetectedTexts([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Process frames continuously
|
||||||
|
const processLoop = useCallback(async () => {
|
||||||
|
if (!videoRef.current || !isStreaming) return;
|
||||||
|
|
||||||
|
const texts = await processVideoFrame(videoRef.current);
|
||||||
|
|
||||||
|
if (texts.length > 0) {
|
||||||
|
setDetectedTexts(texts);
|
||||||
|
onTextDetected(texts);
|
||||||
|
|
||||||
|
// Try to extract structured data
|
||||||
|
const allText = texts.join(' ');
|
||||||
|
|
||||||
|
// ABV
|
||||||
|
const abvMatch = allText.match(PATTERNS.abv);
|
||||||
|
const abv = abvMatch ? parseFloat(abvMatch[1].replace(',', '.')) : null;
|
||||||
|
|
||||||
|
// Age
|
||||||
|
const ageMatch = allText.match(PATTERNS.age);
|
||||||
|
const age = ageMatch ? parseInt(ageMatch[1], 10) : null;
|
||||||
|
|
||||||
|
// Distillery (fuzzy match)
|
||||||
|
let distillery: string | null = null;
|
||||||
|
for (const text of texts) {
|
||||||
|
if (text.length >= 4 && text.length <= 40) {
|
||||||
|
const match = normalizeDistillery(text);
|
||||||
|
if (match.matched) {
|
||||||
|
distillery = match.name;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setExtractedData({ distillery, abv, age });
|
||||||
|
|
||||||
|
// Auto-capture if we have enough data
|
||||||
|
if (distillery && (abv || age) && !isAutoCapturing) {
|
||||||
|
console.log('[NativeOCR] Auto-capture triggered:', { distillery, abv, age });
|
||||||
|
setIsAutoCapturing(true);
|
||||||
|
|
||||||
|
if (onAutoCapture) {
|
||||||
|
onAutoCapture({
|
||||||
|
rawTexts: texts,
|
||||||
|
distillery,
|
||||||
|
abv,
|
||||||
|
age,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visual feedback before closing
|
||||||
|
setTimeout(() => {
|
||||||
|
onClose();
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue loop (throttled to ~5 FPS for performance)
|
||||||
|
animationRef.current = window.setTimeout(() => {
|
||||||
|
requestAnimationFrame(processLoop);
|
||||||
|
}, 200) as unknown as number;
|
||||||
|
}, [isStreaming, processVideoFrame, onTextDetected, onAutoCapture, isAutoCapturing, onClose]);
|
||||||
|
|
||||||
|
// Start/stop based on isOpen
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
startStream();
|
||||||
|
} else {
|
||||||
|
stopStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stopStream();
|
||||||
|
};
|
||||||
|
}, [isOpen, startStream, stopStream]);
|
||||||
|
|
||||||
|
// Start processing loop when streaming
|
||||||
|
useEffect(() => {
|
||||||
|
if (isStreaming) {
|
||||||
|
processLoop();
|
||||||
|
}
|
||||||
|
}, [isStreaming, processLoop]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="absolute top-0 left-0 right-0 z-10 flex items-center justify-between p-4 bg-linear-to-b from-black/80 to-transparent">
|
||||||
|
<div className="flex items-center gap-2 text-white">
|
||||||
|
<Zap size={20} className="text-orange-500" />
|
||||||
|
<span className="font-bold text-sm">Native OCR</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 rounded-full bg-white/10 text-white hover:bg-white/20"
|
||||||
|
>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Video Feed */}
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
playsInline
|
||||||
|
muted
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Scan Overlay */}
|
||||||
|
<div className="absolute inset-0 pointer-events-none">
|
||||||
|
{/* Scan Frame */}
|
||||||
|
<div className="absolute inset-[10%] border-2 border-orange-500/50 rounded-2xl">
|
||||||
|
<div className="absolute top-0 left-0 w-8 h-8 border-t-4 border-l-4 border-orange-500 rounded-tl-xl" />
|
||||||
|
<div className="absolute top-0 right-0 w-8 h-8 border-t-4 border-r-4 border-orange-500 rounded-tr-xl" />
|
||||||
|
<div className="absolute bottom-0 left-0 w-8 h-8 border-b-4 border-l-4 border-orange-500 rounded-bl-xl" />
|
||||||
|
<div className="absolute bottom-0 right-0 w-8 h-8 border-b-4 border-r-4 border-orange-500 rounded-br-xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scanning indicator */}
|
||||||
|
{isStreaming && !isAutoCapturing && (
|
||||||
|
<div className="absolute top-[12%] left-1/2 -translate-x-1/2 flex items-center gap-2 px-4 py-2 bg-black/60 rounded-full text-white text-sm">
|
||||||
|
<Loader2 size={16} className="animate-spin text-orange-500" />
|
||||||
|
Scanning...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Auto-capture success */}
|
||||||
|
{isAutoCapturing && (
|
||||||
|
<div className="absolute top-[12%] left-1/2 -translate-x-1/2 flex items-center gap-2 px-4 py-2 bg-green-600 rounded-full text-white text-sm">
|
||||||
|
<CheckCircle size={16} />
|
||||||
|
Captured!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detected Text Display */}
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 p-4 bg-linear-to-t from-black/90 to-transparent">
|
||||||
|
{extractedData.distillery && (
|
||||||
|
<div className="mb-2 px-3 py-1 bg-orange-600 rounded-full inline-block">
|
||||||
|
<span className="text-white text-sm font-bold">
|
||||||
|
🏭 {extractedData.distillery}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 flex-wrap mb-2">
|
||||||
|
{extractedData.abv && (
|
||||||
|
<span className="px-2 py-1 bg-white/20 rounded-sm text-white text-xs">
|
||||||
|
{extractedData.abv}% ABV
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{extractedData.age && (
|
||||||
|
<span className="px-2 py-1 bg-white/20 rounded-sm text-white text-xs">
|
||||||
|
{extractedData.age} Years
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detectedTexts.length > 0 && (
|
||||||
|
<div className="max-h-20 overflow-y-auto">
|
||||||
|
<p className="text-zinc-400 text-xs">
|
||||||
|
{detectedTexts.slice(0, 5).join(' • ')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!detectedTexts.length && isStreaming && (
|
||||||
|
<p className="text-zinc-500 text-sm text-center">
|
||||||
|
Point camera at the bottle label
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
src/components/NavButton.tsx
Normal file
32
src/components/NavButton.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface NavButtonProps {
|
||||||
|
icon: ReactNode;
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
badge?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NavButton({ icon, label, href, badge }: NavButtonProps) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="flex flex-col items-center justify-center gap-1.5 p-3 bg-zinc-900 hover:bg-zinc-800 border border-zinc-800 hover:border-zinc-700 rounded-xl transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
<div className="relative text-zinc-400">
|
||||||
|
{icon}
|
||||||
|
{badge !== undefined && badge > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 w-4 h-4 bg-orange-600 rounded-full text-[8px] font-black text-white flex items-center justify-center">
|
||||||
|
{badge > 9 ? '9+' : badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-wide">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { usePathname } from 'next/navigation';
|
|||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Scan, GlassWater, Users, Settings, ArrowRight, X, Sparkles } from 'lucide-react';
|
import { Scan, GlassWater, Users, Settings, ArrowRight, X, Sparkles } from 'lucide-react';
|
||||||
import { useI18n } from '@/i18n/I18nContext';
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
|
||||||
const ONBOARDING_KEY = 'dramlog_onboarding_complete';
|
const ONBOARDING_KEY = 'dramlog_onboarding_complete';
|
||||||
|
|
||||||
@@ -50,12 +51,22 @@ const getSteps = (t: (path: string) => string): OnboardingStep[] => [
|
|||||||
|
|
||||||
export default function OnboardingTutorial() {
|
export default function OnboardingTutorial() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { user, isLoading } = useAuth();
|
||||||
const STEPS = getSteps(t);
|
const STEPS = getSteps(t);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Don't show if auth is still loading
|
||||||
|
if (isLoading) return;
|
||||||
|
|
||||||
|
// Don't show if no user is logged in
|
||||||
|
if (!user) {
|
||||||
|
setIsOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Don't show on login/auth pages
|
// Don't show on login/auth pages
|
||||||
if (pathname === '/login' || pathname === '/auth' || pathname === '/register') {
|
if (pathname === '/login' || pathname === '/auth' || pathname === '/register') {
|
||||||
return;
|
return;
|
||||||
@@ -99,7 +110,7 @@ export default function OnboardingTutorial() {
|
|||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
className="fixed inset-0 z-[200] bg-black/90 backdrop-blur-sm flex items-center justify-center p-6"
|
className="fixed inset-0 z-200 bg-black/90 backdrop-blur-xs flex items-center justify-center p-6"
|
||||||
>
|
>
|
||||||
{/* Close button */}
|
{/* Close button */}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export default function PasswordChangeForm() {
|
|||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
className="w-full px-4 py-3 pr-12 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
className="w-full px-4 py-3 pr-12 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-hidden focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -98,7 +98,7 @@ export default function PasswordChangeForm() {
|
|||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-hidden focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Actions Bar */}
|
{/* Actions Bar */}
|
||||||
<div className="bg-zinc-900 rounded-2xl p-6 border border-zinc-800 shadow-sm">
|
<div className="bg-zinc-900 rounded-2xl p-6 border border-zinc-800 shadow-xs">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
@@ -167,10 +167,10 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
|
|||||||
className={`bg-zinc-900 rounded-[32px] p-6 border-2 ${plan.is_active
|
className={`bg-zinc-900 rounded-[32px] p-6 border-2 ${plan.is_active
|
||||||
? 'border-orange-500/30'
|
? 'border-orange-500/30'
|
||||||
: 'border-zinc-800 opacity-60'
|
: 'border-zinc-800 opacity-60'
|
||||||
} shadow-sm relative`}
|
} shadow-xs relative`}
|
||||||
>
|
>
|
||||||
{!plan.is_active && (
|
{!plan.is_active && (
|
||||||
<div className="absolute top-4 right-4 px-2 py-1 bg-zinc-800 text-zinc-400 text-[8px] font-bold uppercase tracking-widest rounded">
|
<div className="absolute top-4 right-4 px-2 py-1 bg-zinc-800 text-zinc-400 text-[8px] font-bold uppercase tracking-widest rounded-sm">
|
||||||
Inactive
|
Inactive
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -210,7 +210,7 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
|
|||||||
|
|
||||||
{/* Edit/Create Modal */}
|
{/* Edit/Create Modal */}
|
||||||
{(editingPlan || isCreating) && (
|
{(editingPlan || isCreating) && (
|
||||||
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
<div className="fixed inset-0 bg-black/80 backdrop-blur-xs flex items-center justify-center p-4 z-50">
|
||||||
<div className="bg-zinc-900 rounded-[32px] p-6 max-w-2xl w-full border border-zinc-800 shadow-2xl">
|
<div className="bg-zinc-900 rounded-[32px] p-6 max-w-2xl w-full border border-zinc-800 shadow-2xl">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h3 className="text-2xl font-bold text-white uppercase tracking-tighter">
|
<h3 className="text-2xl font-bold text-white uppercase tracking-tighter">
|
||||||
@@ -233,7 +233,7 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
|
|||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
placeholder="e.g. starter"
|
placeholder="e.g. starter"
|
||||||
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white"
|
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -243,7 +243,7 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
|
|||||||
value={formData.display_name}
|
value={formData.display_name}
|
||||||
onChange={(e) => setFormData({ ...formData, display_name: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, display_name: e.target.value })}
|
||||||
placeholder="e.g. Starter"
|
placeholder="e.g. Starter"
|
||||||
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white"
|
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -255,7 +255,7 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
|
|||||||
type="number"
|
type="number"
|
||||||
value={formData.monthly_credits}
|
value={formData.monthly_credits}
|
||||||
onChange={(e) => setFormData({ ...formData, monthly_credits: parseInt(e.target.value) || 0 })}
|
onChange={(e) => setFormData({ ...formData, monthly_credits: parseInt(e.target.value) || 0 })}
|
||||||
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white"
|
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -265,7 +265,7 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
value={formData.price}
|
value={formData.price}
|
||||||
onChange={(e) => setFormData({ ...formData, price: parseFloat(e.target.value) || 0 })}
|
onChange={(e) => setFormData({ ...formData, price: parseFloat(e.target.value) || 0 })}
|
||||||
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white"
|
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -277,7 +277,7 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
|
|||||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
placeholder="Brief description of the plan"
|
placeholder="Brief description of the plan"
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white"
|
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -288,7 +288,7 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
|
|||||||
type="number"
|
type="number"
|
||||||
value={formData.sort_order}
|
value={formData.sort_order}
|
||||||
onChange={(e) => setFormData({ ...formData, sort_order: parseInt(e.target.value) || 0 })}
|
onChange={(e) => setFormData({ ...formData, sort_order: parseInt(e.target.value) || 0 })}
|
||||||
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white"
|
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-end">
|
<div className="flex items-end">
|
||||||
@@ -297,7 +297,7 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={formData.is_active}
|
checked={formData.is_active}
|
||||||
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||||
className="w-5 h-5 rounded border-zinc-700 bg-zinc-800 text-orange-600 focus:ring-orange-600"
|
className="w-5 h-5 rounded-sm border-zinc-700 bg-zinc-800 text-orange-600 focus:ring-orange-600"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-bold text-white">Active</span>
|
<span className="text-sm font-bold text-white">Active</span>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export default function ProfileForm({ initialData }: ProfileFormProps) {
|
|||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
placeholder={t('bottle.nameLabel')}
|
placeholder={t('bottle.nameLabel')}
|
||||||
className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-hidden focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
34
src/components/QuickActionsGrid.tsx
Normal file
34
src/components/QuickActionsGrid.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import NavButton from './NavButton';
|
||||||
|
import { Calendar, Users, BarChart3, Heart } from 'lucide-react';
|
||||||
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
|
|
||||||
|
export default function QuickActionsGrid() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
<NavButton
|
||||||
|
icon={<Calendar size={22} />}
|
||||||
|
label={t('nav.sessions') || 'Events'}
|
||||||
|
href="/sessions"
|
||||||
|
/>
|
||||||
|
<NavButton
|
||||||
|
icon={<Users size={22} />}
|
||||||
|
label={t('nav.buddies') || 'Buddies'}
|
||||||
|
href="/buddies"
|
||||||
|
/>
|
||||||
|
<NavButton
|
||||||
|
icon={<BarChart3 size={22} />}
|
||||||
|
label={t('nav.stats') || 'Stats'}
|
||||||
|
href="/stats"
|
||||||
|
/>
|
||||||
|
<NavButton
|
||||||
|
icon={<Heart size={22} />}
|
||||||
|
label={t('nav.wishlist') || 'Wishlist'}
|
||||||
|
href="/wishlist"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ export default function ResultCard({ data, bottleName, image, onShare }: ResultC
|
|||||||
className="flex flex-col items-center gap-6 w-full max-w-sm"
|
className="flex flex-col items-center gap-6 w-full max-w-sm"
|
||||||
>
|
>
|
||||||
{/* The Trading Card */}
|
{/* The Trading Card */}
|
||||||
<div className="relative w-full aspect-[3/4] rounded-[32px] overflow-hidden shadow-[0_20px_60px_rgba(0,0,0,0.9)] border border-zinc-800 bg-zinc-950 group">
|
<div className="relative w-full aspect-3/4 rounded-[32px] overflow-hidden shadow-[0_20px_60px_rgba(0,0,0,0.9)] border border-zinc-800 bg-zinc-950 group">
|
||||||
{/* Bottle Image with Vignette */}
|
{/* Bottle Image with Vignette */}
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
{image ? (
|
{image ? (
|
||||||
@@ -46,7 +46,7 @@ export default function ResultCard({ data, bottleName, image, onShare }: ResultC
|
|||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 bg-zinc-900 flex items-center justify-center opacity-40 text-[20px] font-bold uppercase tracking-[1em] rotate-90">No Data</div>
|
<div className="absolute inset-0 bg-zinc-900 flex items-center justify-center opacity-40 text-[20px] font-bold uppercase tracking-[1em] rotate-90">No Data</div>
|
||||||
)}
|
)}
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-zinc-950 via-zinc-950/20 to-transparent opacity-90" />
|
<div className="absolute inset-0 bg-linear-to-t from-zinc-950 via-zinc-950/20 to-transparent opacity-90" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content Overlay */}
|
{/* Content Overlay */}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useI18n } from '@/i18n/I18nContext';
|
|||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
import { useScanner, ScanStatus } from '@/hooks/useScanner';
|
import { useScanner, ScanStatus } from '@/hooks/useScanner';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
import { useImageProcessor } from '@/hooks/useImageProcessor';
|
||||||
|
|
||||||
type FlowState = 'IDLE' | 'SCANNING' | 'EDITOR' | 'RESULT' | 'ERROR';
|
type FlowState = 'IDLE' | 'SCANNING' | 'EDITOR' | 'RESULT' | 'ERROR';
|
||||||
|
|
||||||
@@ -40,12 +41,13 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
|||||||
const [isEnriching, setIsEnriching] = useState(false);
|
const [isEnriching, setIsEnriching] = useState(false);
|
||||||
const [aiFallbackActive, setAiFallbackActive] = useState(false);
|
const [aiFallbackActive, setAiFallbackActive] = useState(false);
|
||||||
const [pendingTastingData, setPendingTastingData] = useState<any>(null);
|
const [pendingTastingData, setPendingTastingData] = useState<any>(null);
|
||||||
|
const { addToQueue } = useImageProcessor();
|
||||||
|
|
||||||
// Use the Gemini-only scanner hook
|
// Use the AI-powered scanner hook
|
||||||
const scanner = useScanner({
|
const scanner = useScanner({
|
||||||
locale,
|
locale,
|
||||||
onComplete: (cloudResult) => {
|
onComplete: (cloudResult) => {
|
||||||
console.log('[ScanFlow] Gemini complete:', cloudResult);
|
console.log('[ScanFlow] Gemma complete:', cloudResult);
|
||||||
setBottleMetadata(cloudResult);
|
setBottleMetadata(cloudResult);
|
||||||
|
|
||||||
// Trigger background enrichment if we have name and distillery
|
// Trigger background enrichment if we have name and distillery
|
||||||
@@ -202,9 +204,15 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
|||||||
|
|
||||||
const bottleId = bottleResult.data.id;
|
const bottleId = bottleResult.data.id;
|
||||||
|
|
||||||
|
// Queue for background removal
|
||||||
|
if (scanner.processedImage?.file) {
|
||||||
|
addToQueue(bottleId, scanner.processedImage.file);
|
||||||
|
}
|
||||||
|
|
||||||
const tastingNote = {
|
const tastingNote = {
|
||||||
...formData,
|
...formData,
|
||||||
bottle_id: bottleId,
|
bottle_id: bottleId,
|
||||||
|
session_id: activeSession?.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
const tastingResult = await saveTasting(tastingNote);
|
const tastingResult = await saveTasting(tastingNote);
|
||||||
@@ -264,6 +272,11 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
|||||||
locale,
|
locale,
|
||||||
metadata: bottleDataToSave as any
|
metadata: bottleDataToSave as any
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Queue for background removal using temp_id
|
||||||
|
if (scanner.processedImage?.file) {
|
||||||
|
addToQueue(tempId, scanner.processedImage.file);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.pending_tastings.add({
|
await db.pending_tastings.add({
|
||||||
@@ -341,12 +354,12 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
|||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
className="fixed inset-0 z-[60] bg-zinc-950 flex flex-col h-[100dvh] w-screen overflow-hidden overscroll-none"
|
className="fixed inset-0 z-60 bg-zinc-950 flex flex-col h-dvh w-screen overflow-hidden overscroll-none"
|
||||||
>
|
>
|
||||||
{/* Close Button */}
|
{/* Close Button */}
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="absolute top-6 right-6 z-[70] p-2 rounded-full bg-zinc-900 border border-zinc-800 text-zinc-400 hover:text-white transition-colors"
|
className="absolute top-6 right-6 z-70 p-2 rounded-full bg-zinc-900 border border-zinc-800 text-zinc-400 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
<X size={24} />
|
<X size={24} />
|
||||||
</button>
|
</button>
|
||||||
@@ -507,7 +520,7 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
|||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
className="absolute inset-0 z-[80] bg-zinc-950/80 backdrop-blur-sm flex flex-col items-center justify-center gap-6"
|
className="absolute inset-0 z-80 bg-zinc-950/80 backdrop-blur-xs flex flex-col items-center justify-center gap-6"
|
||||||
>
|
>
|
||||||
<Loader2 size={48} className="animate-spin text-orange-600" />
|
<Loader2 size={48} className="animate-spin text-orange-600" />
|
||||||
<h2 className="text-xl font-bold text-zinc-50 uppercase tracking-tight">
|
<h2 className="text-xl font-bold text-zinc-50 uppercase tracking-tight">
|
||||||
|
|||||||
48
src/components/SentryInit.tsx
Normal file
48
src/components/SentryInit.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
|
||||||
|
export default function SentryInit() {
|
||||||
|
useEffect(() => {
|
||||||
|
const dsn = process.env.NEXT_PUBLIC_GLITCHTIP_DSN;
|
||||||
|
|
||||||
|
console.log('[Sentry Debug] NEXT_PUBLIC_GLITCHTIP_DSN:', dsn ? dsn.substring(0, 40) + '...' : 'NOT SET');
|
||||||
|
|
||||||
|
if (!dsn) {
|
||||||
|
console.warn('[Sentry] Client disabled - no DSN configured');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if already initialized
|
||||||
|
const existingClient = Sentry.getClient();
|
||||||
|
if (existingClient) {
|
||||||
|
console.log('[Sentry] Already initialized, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn,
|
||||||
|
environment: process.env.NODE_ENV || 'development',
|
||||||
|
sampleRate: 1.0,
|
||||||
|
tracesSampleRate: 0.1,
|
||||||
|
tunnel: '/api/glitchtip-tunnel',
|
||||||
|
debug: true,
|
||||||
|
beforeSend(event) {
|
||||||
|
console.log('[Sentry] Sending event:', event.event_id);
|
||||||
|
return event;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[Sentry] ✅ Client initialized successfully');
|
||||||
|
|
||||||
|
// Test that it works
|
||||||
|
console.log('[Sentry] Client:', Sentry.getClient() ? 'OK' : 'FAILED');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Sentry] Initialization error:', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Activity, AlertCircle, TrendingUp, Zap } from 'lucide-react';
|
import { Activity, AlertCircle, CheckCircle, Zap, TrendingUp } from 'lucide-react';
|
||||||
|
import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, YAxis, CartesianGrid } from 'recharts';
|
||||||
|
|
||||||
interface ABVTasting {
|
interface ABVTasting {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,116 +17,121 @@ interface SessionABVCurveProps {
|
|||||||
export default function SessionABVCurve({ tastings }: SessionABVCurveProps) {
|
export default function SessionABVCurve({ tastings }: SessionABVCurveProps) {
|
||||||
if (!tastings || tastings.length < 2) {
|
if (!tastings || tastings.length < 2) {
|
||||||
return (
|
return (
|
||||||
<div className="p-6 bg-zinc-50 dark:bg-zinc-900/50 rounded-3xl border border-dashed border-zinc-200 dark:border-zinc-800 text-center">
|
<div className="p-8 bg-zinc-900 rounded-3xl border border-dashed border-zinc-800 text-center">
|
||||||
<Activity size={24} className="mx-auto text-zinc-300 mb-2" />
|
<Activity size={32} className="mx-auto text-zinc-700 mb-3" />
|
||||||
<p className="text-[10px] text-zinc-500 font-bold uppercase tracking-widest">Kurve wird ab 2 Drams berechnet</p>
|
<p className="text-[10px] text-zinc-500 font-bold uppercase tracking-widest leading-relaxed">
|
||||||
|
Kurve wird ab 2 Drams berechnet
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sorted = [...tastings].sort((a, b) => new Date(a.tasted_at).getTime() - new Date(b.tasted_at).getTime());
|
const data = [...tastings]
|
||||||
|
.sort((a, b) => new Date(a.tasted_at).getTime() - new Date(b.tasted_at).getTime())
|
||||||
|
.map((t: ABVTasting, i: number) => ({
|
||||||
|
name: `Dram ${i + 1}`,
|
||||||
|
abv: t.abv,
|
||||||
|
timestamp: new Date(t.tasted_at).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }),
|
||||||
|
id: t.id
|
||||||
|
}));
|
||||||
|
|
||||||
// Normalize data: Y-axis is ABV (say 40-65 range), X-axis is time or just sequence index
|
const hasBigJump = tastings.some((t: ABVTasting, i: number) => i > 0 && Math.abs(t.abv - tastings[i - 1].abv) > 10);
|
||||||
const minAbv = Math.min(...sorted.map(t => t.abv));
|
const avgAbv = (tastings.reduce((acc: number, t: ABVTasting) => acc + t.abv, 0) / tastings.length).toFixed(1);
|
||||||
const maxAbv = Math.max(...sorted.map(t => t.abv));
|
|
||||||
const range = Math.max(maxAbv - minAbv, 10); // at least 10 point range for scale
|
|
||||||
|
|
||||||
// SVG Dimensions
|
|
||||||
const width = 400;
|
|
||||||
const height = 150;
|
|
||||||
const padding = 20;
|
|
||||||
|
|
||||||
const getX = (index: number) => padding + (index * (width - 2 * padding) / (sorted.length - 1));
|
|
||||||
const getY = (abv: number) => {
|
|
||||||
const normalized = (abv - (minAbv - 2)) / (range + 4);
|
|
||||||
return height - padding - (normalized * (height - 2 * padding));
|
|
||||||
};
|
|
||||||
|
|
||||||
const points = sorted.map((t, i) => `${getX(i)},${getY(t.abv)}`).join(' ');
|
|
||||||
|
|
||||||
// Check for dangerous slope (sudden high ABV jump)
|
|
||||||
const hasBigJump = sorted.some((t, i) => i > 0 && t.abv - sorted[i - 1].abv > 10);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-zinc-900 rounded-3xl p-5 border border-white/5 shadow-2xl overflow-hidden relative group">
|
<div className="bg-zinc-900 rounded-3xl p-6 border border-white/5 shadow-2xl relative group overflow-hidden">
|
||||||
<div className="flex items-center justify-between mb-4">
|
{/* Header */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-between mb-8">
|
||||||
<TrendingUp size={16} className="text-amber-500" />
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-orange-500/10 rounded-xl">
|
||||||
|
<TrendingUp size={18} className="text-orange-500" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-[10px] font-black text-zinc-500 uppercase tracking-widest leading-none">ABV Kurve (Session)</h4>
|
<h4 className="text-[10px] font-black text-zinc-500 uppercase tracking-widest leading-none mb-1">ABV Progression</h4>
|
||||||
<p className="text-[8px] text-zinc-600 font-bold uppercase tracking-tighter">Alcohol By Volume Progression</p>
|
<p className="text-[8px] text-zinc-600 font-bold uppercase tracking-tighter">Alcohol By Volume Intensity</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{hasBigJump && (
|
{hasBigJump && (
|
||||||
<div className="flex items-center gap-1.5 px-2 py-1 bg-red-500/10 border border-red-500/20 rounded-lg animate-pulse">
|
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-red-500/10 border border-red-500/20 rounded-full">
|
||||||
<AlertCircle size={10} className="text-red-500" />
|
<AlertCircle size={10} className="text-red-500" />
|
||||||
<span className="text-[8px] font-black text-red-500 uppercase tracking-tighter">Zick-Zack Gefahr</span>
|
<span className="text-[8px] font-black text-red-500 uppercase tracking-widest">Spike Alert</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative h-[150px] w-full">
|
{/* Chart Container */}
|
||||||
{/* Grid Lines */}
|
<div className="h-[180px] w-full -ml-4">
|
||||||
<div className="absolute inset-0 flex flex-col justify-between opacity-10 pointer-events-none">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
{[1, 2, 3, 4].map(i => <div key={i} className="border-t border-white" />)}
|
<AreaChart data={data}>
|
||||||
</div>
|
|
||||||
|
|
||||||
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-full drop-shadow-[0_0_15px_rgba(217,119,6,0.2)]">
|
|
||||||
{/* Gradient under line */}
|
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="curveGradient" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="abvGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="0%" stopColor="#d97706" stopOpacity="0.4" />
|
<stop offset="5%" stopColor="#ea580c" stopOpacity={0.3} />
|
||||||
<stop offset="100%" stopColor="#d97706" stopOpacity="0" />
|
<stop offset="95%" stopColor="#ea580c" stopOpacity={0} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#ffffff05" />
|
||||||
<path
|
<XAxis
|
||||||
d={`M ${getX(0)} ${height} L ${points} L ${getX(sorted.length - 1)} ${height} Z`}
|
dataKey="name"
|
||||||
fill="url(#curveGradient)"
|
hide
|
||||||
/>
|
/>
|
||||||
|
<YAxis
|
||||||
<polyline
|
domain={['dataMin - 5', 'dataMax + 5']}
|
||||||
points={points}
|
hide
|
||||||
fill="none"
|
|
||||||
stroke="#d97706"
|
|
||||||
strokeWidth="3"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
className="transition-all duration-700 ease-out"
|
|
||||||
/>
|
/>
|
||||||
|
<Tooltip
|
||||||
{sorted.map((t, i) => (
|
content={({ active, payload }) => {
|
||||||
<g key={t.id} className="group/dot">
|
if (active && payload && payload.length) {
|
||||||
<circle
|
return (
|
||||||
cx={getX(i)}
|
<div className="bg-zinc-950 border border-white/10 p-3 rounded-2xl shadow-2xl backdrop-blur-xl">
|
||||||
cy={getY(t.abv)}
|
<p className="text-[10px] font-black text-zinc-500 uppercase tracking-widest mb-1">
|
||||||
r="4"
|
{payload[0].payload.name} • {payload[0].payload.timestamp}
|
||||||
fill="#d97706"
|
</p>
|
||||||
className="transition-all hover:r-6 cursor-help"
|
<p className="text-xl font-black text-white">
|
||||||
|
{payload[0].value}% <span className="text-[10px] text-zinc-500">ABV</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<text
|
<Area
|
||||||
x={getX(i)}
|
type="monotone"
|
||||||
y={getY(t.abv) - 10}
|
dataKey="abv"
|
||||||
textAnchor="middle"
|
stroke="#ea580c"
|
||||||
className="text-[8px] fill-zinc-400 font-black opacity-0 group-hover/dot:opacity-100 transition-opacity"
|
strokeWidth={3}
|
||||||
>
|
fillOpacity={1}
|
||||||
{t.abv}%
|
fill="url(#abvGradient)"
|
||||||
</text>
|
animationDuration={1500}
|
||||||
</g>
|
/>
|
||||||
))}
|
</AreaChart>
|
||||||
</svg>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-2 gap-3 border-t border-white/5 pt-4">
|
{/* Stats Footer */}
|
||||||
<div className="flex flex-col">
|
<div className="mt-6 grid grid-cols-2 gap-4 border-t border-white/5 pt-6">
|
||||||
<span className="text-[8px] font-black text-zinc-500 uppercase tracking-widest">Ø Alkohol</span>
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-sm font-black text-white">{(sorted.reduce((acc, t) => acc + t.abv, 0) / sorted.length).toFixed(1)}%</span>
|
<span className="text-[9px] font-black text-zinc-500 uppercase tracking-[0.2em]">Ø Intensity</span>
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className="text-2xl font-black text-white tracking-tighter">{avgAbv}</span>
|
||||||
|
<span className="text-[10px] font-bold text-zinc-500 uppercase">%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<span className="text-[9px] font-black text-zinc-500 uppercase tracking-[0.2em]">Flow State</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{hasBigJump ? (
|
||||||
|
<>
|
||||||
|
<Zap size={14} className="text-red-500" />
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-widest text-red-500">Aggressive</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle size={14} className="text-green-500" />
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-widest text-green-500">Smooth</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end">
|
|
||||||
<span className="text-[8px] font-black text-zinc-500 uppercase tracking-widest">Status</span>
|
|
||||||
<span className={`text-[10px] font-black uppercase tracking-widest ${hasBigJump ? 'text-red-500' : 'text-green-500'}`}>
|
|
||||||
{hasBigJump ? 'Instabil' : 'Optimal'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export default function SessionBottomSheet({ isOpen, onClose }: SessionBottomShe
|
|||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="fixed inset-0 bg-zinc-950/80 backdrop-blur-sm z-[80]"
|
className="fixed inset-0 bg-zinc-950/80 backdrop-blur-xs z-80"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Sheet */}
|
{/* Sheet */}
|
||||||
@@ -85,7 +85,7 @@ export default function SessionBottomSheet({ isOpen, onClose }: SessionBottomShe
|
|||||||
animate={{ y: 0 }}
|
animate={{ y: 0 }}
|
||||||
exit={{ y: '100%' }}
|
exit={{ y: '100%' }}
|
||||||
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
||||||
className="fixed bottom-0 left-0 right-0 bg-[var(--background)] border-t border-white/5 rounded-t-[40px] z-[90] p-8 pb-12 max-h-[85vh] overflow-y-auto shadow-[0_-20px_60px_rgba(0,0,0,0.8)] ring-1 ring-white/5"
|
className="fixed bottom-0 left-0 right-0 bg-(--background) border-t border-white/5 rounded-t-[40px] z-90 p-8 pb-12 max-h-[85vh] overflow-y-auto shadow-[0_-20px_60px_rgba(0,0,0,0.8)] ring-1 ring-white/5"
|
||||||
>
|
>
|
||||||
{/* Drag Handle */}
|
{/* Drag Handle */}
|
||||||
<div className="w-10 h-1 bg-white/10 rounded-full mx-auto mb-8" />
|
<div className="w-10 h-1 bg-white/10 rounded-full mx-auto mb-8" />
|
||||||
@@ -100,7 +100,7 @@ export default function SessionBottomSheet({ isOpen, onClose }: SessionBottomShe
|
|||||||
onChange={(e) => setNewSessionName(e.target.value)}
|
onChange={(e) => setNewSessionName(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleCreateSession()}
|
onKeyDown={(e) => e.key === 'Enter' && handleCreateSession()}
|
||||||
placeholder="Neue Session erstellen..."
|
placeholder="Neue Session erstellen..."
|
||||||
className="w-full bg-zinc-900 border border-zinc-800 rounded-2xl py-4 px-6 text-zinc-50 focus:outline-none focus:border-orange-600 transition-colors"
|
className="w-full bg-zinc-900 border border-zinc-800 rounded-2xl py-4 px-6 text-zinc-50 focus:outline-hidden focus:border-orange-600 transition-colors"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleCreateSession}
|
onClick={handleCreateSession}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
import { Calendar, Plus, GlassWater, Loader2, ChevronRight, Users, Check, Trash2, ChevronDown, ChevronUp } from 'lucide-react';
|
import { Calendar, Plus, GlassWater, Loader2, ChevronRight, Users, Check, Trash2, ChevronDown, ChevronUp, Play, Sparkles } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import AvatarStack from './AvatarStack';
|
import AvatarStack from './AvatarStack';
|
||||||
import { deleteSession } from '@/services/delete-session';
|
import { deleteSession } from '@/services/delete-session';
|
||||||
@@ -170,7 +170,7 @@ export default function SessionList() {
|
|||||||
value={newName}
|
value={newName}
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
placeholder={t('session.sessionName')}
|
placeholder={t('session.sessionName')}
|
||||||
className="flex-1 bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2 text-sm text-zinc-50 placeholder:text-zinc-600 focus:outline-none focus:border-orange-600 transition-colors"
|
className="flex-1 bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2 text-sm text-zinc-50 placeholder:text-zinc-600 focus:outline-hidden focus:border-orange-600 transition-colors"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -182,45 +182,52 @@ export default function SessionList() {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex justify-center py-8 text-zinc-500">
|
<div className="flex justify-center py-12 text-zinc-700">
|
||||||
<Loader2 size={24} className="animate-spin" />
|
<Loader2 size={32} className="animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : sessions.length === 0 ? (
|
) : sessions.length === 0 ? (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-12 bg-zinc-950/50 rounded-[32px] border border-dashed border-zinc-800">
|
||||||
<div className="w-14 h-14 mx-auto rounded-2xl bg-zinc-800/50 flex items-center justify-center mb-4">
|
<div className="w-16 h-16 mx-auto rounded-full bg-zinc-900 flex items-center justify-center mb-6 border border-white/5 shadow-inner">
|
||||||
<Calendar size={24} className="text-zinc-500" />
|
<Calendar size={28} className="text-zinc-700" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-bold text-zinc-400 mb-1">Keine Sessions</p>
|
<p className="text-sm font-black text-zinc-400 mb-2 uppercase tracking-widest">{t('session.noSessions') || 'Keine Sessions'}</p>
|
||||||
<p className="text-xs text-zinc-600 max-w-[200px] mx-auto">
|
<p className="text-[10px] text-zinc-600 font-bold uppercase tracking-tight max-w-[200px] mx-auto leading-relaxed">
|
||||||
Erstelle eine Tasting-Session um mehrere Whiskys zu vergleichen
|
Erstelle eine Tasting-Session um deine Drams zeitlich zu ordnen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
{sessions.map((session) => (
|
{sessions.map((session) => (
|
||||||
<div
|
<div
|
||||||
key={session.id}
|
key={session.id}
|
||||||
className={`flex items-center justify-between p-4 rounded-2xl border transition-all ${activeSession?.id === session.id
|
className={`group relative flex items-center justify-between p-5 rounded-[28px] border transition-all duration-500 overflow-hidden ${activeSession?.id === session.id
|
||||||
? 'bg-orange-600 border-orange-600 shadow-lg shadow-orange-950/20'
|
? 'bg-orange-500/3 border-orange-500/40 shadow-[0_0_40px_rgba(234,88,12,0.1)]'
|
||||||
: 'bg-zinc-950 border-zinc-800 hover:border-zinc-700'
|
: 'bg-zinc-950/50 border-white/5 hover:border-white/10'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Link href={`/sessions/${session.id}`} className="flex-1 space-y-1 min-w-0">
|
{/* Active Glow Decor */}
|
||||||
<div className={`font-bold text-lg truncate flex items-center gap-2 ${activeSession?.id === session.id ? 'text-white' : 'text-zinc-50'}`}>
|
{activeSession?.id === session.id && (
|
||||||
|
<div className="absolute top-0 right-0 w-32 h-32 bg-orange-600/10 blur-[60px] -mr-16 -mt-16 pointer-events-none" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link href={`/sessions/${session.id}`} className="flex-1 space-y-2 min-w-0 z-10">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`font-black text-xl tracking-tight truncate ${activeSession?.id === session.id ? 'text-white' : 'text-zinc-200 group-hover:text-white transition-colors'}`}>
|
||||||
{session.name}
|
{session.name}
|
||||||
|
</div>
|
||||||
{session.ended_at && (
|
{session.ended_at && (
|
||||||
<span className={`text-[8px] font-bold uppercase px-1.5 py-0.5 rounded border ${activeSession?.id === session.id ? 'bg-black/10 border-black/20 text-white' : 'bg-zinc-800 border-zinc-700 text-zinc-500'}`}>Closed</span>
|
<span className="text-[8px] font-black uppercase px-2 py-0.5 rounded-full bg-zinc-800/50 border border-zinc-700/50 text-zinc-500 tracking-widest">Archiv</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={`flex items-center gap-4 text-[10px] font-bold uppercase tracking-widest ${activeSession?.id === session.id ? 'text-white/60' : 'text-zinc-500'}`}>
|
<div className={`flex items-center gap-5 text-[10px] font-black uppercase tracking-[0.15em] ${activeSession?.id === session.id ? 'text-orange-500/80' : 'text-zinc-500'}`}>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-2">
|
||||||
<Calendar size={12} />
|
<Calendar size={13} strokeWidth={2.5} />
|
||||||
{new Date(session.scheduled_at).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')}
|
{new Date(session.scheduled_at).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')}
|
||||||
</span>
|
</span>
|
||||||
{session.whisky_count! > 0 && (
|
{session.whisky_count! > 0 && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-2">
|
||||||
<GlassWater size={12} />
|
<GlassWater size={13} strokeWidth={2.5} />
|
||||||
{session.whisky_count} Whiskys
|
{session.whisky_count}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -230,34 +237,37 @@ export default function SessionList() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
|
<div className="flex items-center gap-1 z-10">
|
||||||
{activeSession?.id !== session.id ? (
|
{activeSession?.id !== session.id ? (
|
||||||
!session.ended_at ? (
|
!session.ended_at ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveSession({ id: session.id, name: session.name })}
|
onClick={(e) => {
|
||||||
className="p-2 bg-zinc-800 text-zinc-50 rounded-xl hover:bg-orange-600 hover:text-white transition-all"
|
e.preventDefault();
|
||||||
|
setActiveSession({ id: session.id, name: session.name });
|
||||||
|
}}
|
||||||
|
className="p-3 text-zinc-600 hover:text-orange-500 transition-all hover:scale-110 active:scale-95"
|
||||||
title="Start Session"
|
title="Start Session"
|
||||||
>
|
>
|
||||||
<GlassWater size={18} />
|
<Play size={22} fill="currentColor" className="opacity-40" />
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-2 bg-zinc-900 text-zinc-500 rounded-xl border border-zinc-800 opacity-50">
|
<div className="p-3 text-zinc-800">
|
||||||
<Check size={18} />
|
<Check size={20} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="p-2 bg-black/10 text-white rounded-xl">
|
<div className="p-3 text-orange-500 animate-pulse">
|
||||||
<Check size={18} />
|
<Sparkles size={20} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ChevronRight size={20} className={activeSession?.id === session.id ? 'text-white/40' : 'text-zinc-700'} />
|
|
||||||
|
<div className="w-px h-8 bg-white/5 mx-1" />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={(e) => handleDeleteSession(e, session.id)}
|
onClick={(e) => handleDeleteSession(e, session.id)}
|
||||||
disabled={!!isDeleting}
|
disabled={!!isDeleting}
|
||||||
className={`p-2 rounded-xl transition-all ${activeSession?.id === session.id
|
className="p-3 text-zinc-700 hover:text-red-500 transition-all opacity-0 group-hover:opacity-100"
|
||||||
? 'text-white/40 hover:text-white'
|
|
||||||
: 'text-zinc-600 hover:text-red-500'
|
|
||||||
}`}
|
|
||||||
title="Session löschen"
|
title="Session löschen"
|
||||||
>
|
>
|
||||||
{isDeleting === session.id ? (
|
{isDeleting === session.id ? (
|
||||||
@@ -266,6 +276,7 @@ export default function SessionList() {
|
|||||||
<Trash2 size={18} />
|
<Trash2 size={18} />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
<ChevronRight size={20} className="text-zinc-800 group-hover:text-zinc-600 transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -278,12 +289,12 @@ export default function SessionList() {
|
|||||||
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-top-1">
|
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-top-1">
|
||||||
<div className="flex -space-x-1.5 overflow-hidden">
|
<div className="flex -space-x-1.5 overflow-hidden">
|
||||||
{sessions.slice(0, 3).map((s, i) => (
|
{sessions.slice(0, 3).map((s, i) => (
|
||||||
<div key={s.id} className="w-7 h-7 rounded-lg bg-zinc-950 border border-zinc-800 flex items-center justify-center text-[10px] font-bold text-orange-600 shadow-sm">
|
<div key={s.id} className="w-7 h-7 rounded-lg bg-zinc-950 border border-zinc-800 flex items-center justify-center text-[10px] font-bold text-orange-600 shadow-xs">
|
||||||
{s.name[0].toUpperCase()}
|
{s.name[0].toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{sessions.length > 3 && (
|
{sessions.length > 3 && (
|
||||||
<div className="w-7 h-7 rounded-lg bg-zinc-950 border border-zinc-800 flex items-center justify-center text-[8px] font-bold text-zinc-500 shadow-sm">
|
<div className="w-7 h-7 rounded-lg bg-zinc-950 border border-zinc-800 flex items-center justify-center text-[8px] font-bold text-zinc-500 shadow-xs">
|
||||||
+{sessions.length - 3}
|
+{sessions.length - 3}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -17,12 +17,14 @@ interface TimelineTasting {
|
|||||||
interface SessionTimelineProps {
|
interface SessionTimelineProps {
|
||||||
tastings: TimelineTasting[];
|
tastings: TimelineTasting[];
|
||||||
sessionStart?: string;
|
sessionStart?: string;
|
||||||
|
isBlind?: boolean;
|
||||||
|
isRevealed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keywords that indicate a "Peat Bomb"
|
// Keywords that indicate a "Peat Bomb"
|
||||||
const SMOKY_KEYWORDS = ['rauch', 'torf', 'smoke', 'peat', 'islay', 'ash', 'lagerfeuer', 'campfire', 'asphalte'];
|
const SMOKY_KEYWORDS = ['rauch', 'torf', 'smoke', 'peat', 'islay', 'ash', 'lagerfeuer', 'campfire', 'asphalte'];
|
||||||
|
|
||||||
export default function SessionTimeline({ tastings, sessionStart }: SessionTimelineProps) {
|
export default function SessionTimeline({ tastings, sessionStart, isBlind, isRevealed }: SessionTimelineProps) {
|
||||||
if (!tastings || tastings.length === 0) {
|
if (!tastings || tastings.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="p-8 text-center bg-zinc-50 dark:bg-zinc-900/50 rounded-3xl border border-dashed border-zinc-200 dark:border-zinc-800">
|
<div className="p-8 text-center bg-zinc-50 dark:bg-zinc-900/50 rounded-3xl border border-dashed border-zinc-200 dark:border-zinc-800">
|
||||||
@@ -51,6 +53,10 @@ export default function SessionTimeline({ tastings, sessionStart }: SessionTimel
|
|||||||
const currentTime = tastedDate.getTime();
|
const currentTime = tastedDate.getTime();
|
||||||
const diffMinutes = Math.round((currentTime - firstTastingTime) / (1000 * 60));
|
const diffMinutes = Math.round((currentTime - firstTastingTime) / (1000 * 60));
|
||||||
const wallClockTime = tastedDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
const wallClockTime = tastedDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
|
||||||
|
// Blind Mode logic
|
||||||
|
const showDetails = !isBlind || isRevealed;
|
||||||
|
const displayName = showDetails ? tasting.bottle_name : `Sample ${String.fromCharCode(65 + index)}`;
|
||||||
const isSmoky = checkIsSmoky(tasting);
|
const isSmoky = checkIsSmoky(tasting);
|
||||||
|
|
||||||
const wasPreviousSmoky = index > 0 && checkIsSmoky(sortedTastings[index - 1]);
|
const wasPreviousSmoky = index > 0 && checkIsSmoky(sortedTastings[index - 1]);
|
||||||
@@ -61,11 +67,11 @@ export default function SessionTimeline({ tastings, sessionStart }: SessionTimel
|
|||||||
return (
|
return (
|
||||||
<div key={tasting.id} className="relative group">
|
<div key={tasting.id} className="relative group">
|
||||||
{/* Dot */}
|
{/* Dot */}
|
||||||
<div className={`absolute -left-[22px] top-4 w-5 h-5 rounded-full border-[3px] border-zinc-950 shadow-sm z-10 flex items-center justify-center ${isSmoky ? 'bg-orange-600' : 'bg-zinc-600'}`}>
|
<div className={`absolute -left-[22px] top-4 w-5 h-5 rounded-full border-[3px] border-zinc-950 shadow-xs z-10 flex items-center justify-center ${isSmoky && showDetails ? 'bg-orange-600' : 'bg-zinc-600'}`}>
|
||||||
{isSmoky && <Droplets size={8} className="text-white fill-white" />}
|
{isSmoky && showDetails && <Droplets size={8} className="text-white fill-white" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-zinc-900 p-4 rounded-2xl border border-zinc-800 shadow-sm hover:shadow-md transition-shadow group-hover:border-orange-500/30">
|
<div className="bg-zinc-900 p-4 rounded-2xl border border-zinc-800 shadow-xs hover:shadow-md transition-shadow group-hover:border-orange-500/30">
|
||||||
<div className="flex justify-between items-start gap-3">
|
<div className="flex justify-between items-start gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
@@ -73,31 +79,53 @@ export default function SessionTimeline({ tastings, sessionStart }: SessionTimel
|
|||||||
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-tight leading-none">
|
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-tight leading-none">
|
||||||
{wallClockTime} ({index === 0 ? 'Start' : `+${diffMinutes}m`})
|
{wallClockTime} ({index === 0 ? 'Start' : `+${diffMinutes}m`})
|
||||||
</span>
|
</span>
|
||||||
{isSmoky && (
|
{isSmoky && showDetails && (
|
||||||
<span className="bg-orange-900/40 text-orange-500 text-[8px] font-bold px-1.5 py-0.5 rounded-md uppercase tracking-tighter border border-orange-500/20">Peat Bomb</span>
|
<span className="bg-orange-900/40 text-orange-500 text-[8px] font-bold px-1.5 py-0.5 rounded-md uppercase tracking-tighter border border-orange-500/20">Peat Bomb</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showDetails ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/bottles/${tasting.bottle_id}`}
|
href={`/bottles/${tasting.bottle_id}`}
|
||||||
className="text-sm font-bold text-zinc-100 hover:text-orange-600 truncate block mt-0.5 uppercase tracking-tight"
|
className="text-sm font-bold text-zinc-100 hover:text-orange-600 truncate block mt-0.5 uppercase tracking-tight"
|
||||||
>
|
>
|
||||||
{tasting.bottle_name}
|
{displayName}
|
||||||
</Link>
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm font-bold text-zinc-100 bg-zinc-800/30 blur-xs px-2 py-0.5 rounded-md select-none">
|
||||||
|
Unknown Bottle
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!showDetails && (
|
||||||
|
<div className="mt-1 text-purple-500 font-black uppercase text-[12px] tracking-tight">
|
||||||
|
{displayName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mt-2 flex flex-wrap gap-1">
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
{tasting.tags.slice(0, 2).map(tag => (
|
{showDetails ? (
|
||||||
|
tasting.tags.slice(0, 2).map(tag => (
|
||||||
<span key={tag} className="text-[9px] text-zinc-500 bg-zinc-800/50 px-2 py-0.5 rounded-full border border-zinc-800">
|
<span key={tag} className="text-[9px] text-zinc-500 bg-zinc-800/50 px-2 py-0.5 rounded-full border border-zinc-800">
|
||||||
{tag}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-[9px] text-zinc-600 bg-zinc-900 px-2 py-0.5 rounded-full border border-zinc-800 italic">
|
||||||
|
Noten versteckt...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 flex flex-col items-end">
|
<div className="shrink-0 flex flex-col items-end">
|
||||||
<div className="text-lg font-bold text-zinc-50 leading-none">{tasting.rating}</div>
|
<div className="text-lg font-bold text-zinc-50 leading-none">
|
||||||
|
{showDetails ? tasting.rating : '?'}
|
||||||
|
</div>
|
||||||
<div className="text-[9px] font-bold text-zinc-500 uppercase tracking-tighter mt-1">Punkte</div>
|
<div className="text-[9px] font-bold text-zinc-500 uppercase tracking-tighter mt-1">Punkte</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{wasPreviousSmoky && timeSinceLastDram < 20 && (
|
{wasPreviousSmoky && timeSinceLastDram < 20 && showDetails && (
|
||||||
<div className="mt-4 p-2 bg-orange-900/10 border border-orange-900/30 rounded-xl flex items-center gap-2 animate-in slide-in-from-top-1">
|
<div className="mt-4 p-2 bg-orange-900/10 border border-orange-900/30 rounded-xl flex items-center gap-2 animate-in slide-in-from-top-1">
|
||||||
<AlertTriangle size={12} className="text-orange-600 shrink-0" />
|
<AlertTriangle size={12} className="text-orange-600 shrink-0" />
|
||||||
<p className="text-[9px] text-orange-400 font-bold leading-tight">
|
<p className="text-[9px] text-orange-400 font-bold leading-tight">
|
||||||
|
|||||||
102
src/components/Skeletons.tsx
Normal file
102
src/components/Skeletons.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
// Generic Skeleton components for Suspense fallbacks
|
||||||
|
|
||||||
|
export function TastingListSkeleton({ count = 3 }: { count?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="bg-zinc-900 border border-zinc-800 rounded-2xl p-4 animate-pulse"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-12 h-12 bg-zinc-800 rounded-xl shrink-0" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="h-4 bg-zinc-800 rounded w-3/4" />
|
||||||
|
<div className="h-3 bg-zinc-800 rounded w-1/2" />
|
||||||
|
<div className="h-3 bg-zinc-800 rounded w-1/4" />
|
||||||
|
</div>
|
||||||
|
<div className="w-10 h-10 bg-zinc-800 rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BottleDetailsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-pulse">
|
||||||
|
{/* Image skeleton */}
|
||||||
|
<div className="aspect-square bg-zinc-900 rounded-3xl" />
|
||||||
|
|
||||||
|
{/* Title skeleton */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-8 bg-zinc-800 rounded w-3/4" />
|
||||||
|
<div className="h-5 bg-zinc-800 rounded w-1/2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Badges skeleton */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="h-8 w-20 bg-zinc-800 rounded-xl" />
|
||||||
|
<div className="h-8 w-16 bg-zinc-800 rounded-xl" />
|
||||||
|
<div className="h-8 w-24 bg-zinc-800 rounded-xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats skeleton */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="h-20 bg-zinc-900 rounded-2xl" />
|
||||||
|
<div className="h-20 bg-zinc-900 rounded-2xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChartSkeleton({ height = 200 }: { height?: number }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bg-zinc-900 border border-zinc-800 rounded-2xl animate-pulse flex items-center justify-center"
|
||||||
|
style={{ height }}
|
||||||
|
>
|
||||||
|
<div className="text-zinc-700 text-sm">Loading chart...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatsCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-4 animate-pulse">
|
||||||
|
<div className="h-3 bg-zinc-800 rounded w-1/2 mb-2" />
|
||||||
|
<div className="h-8 bg-zinc-800 rounded w-1/3" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionTimelineSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 animate-pulse">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-4">
|
||||||
|
<div className="w-3 h-3 bg-zinc-700 rounded-full" />
|
||||||
|
<div className="flex-1 h-16 bg-zinc-900 border border-zinc-800 rounded-xl" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GridSkeleton({ count = 6 }: { count?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="aspect-[3/4] bg-zinc-900 border border-zinc-800 rounded-2xl animate-pulse"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -56,7 +56,7 @@ export default function SplitCard({ split, isParticipant, onSelect, showChevron
|
|||||||
<Package size={10} />
|
<Package size={10} />
|
||||||
{split.amountCl}cl
|
{split.amountCl}cl
|
||||||
</span>
|
</span>
|
||||||
<span className={`px-1.5 py-0.5 rounded bg-white/5 border border-white/5 ${split.status === 'SHIPPED' ? 'text-green-500' : 'text-zinc-400'}`}>
|
<span className={`px-1.5 py-0.5 rounded-sm bg-white/5 border border-white/5 ${split.status === 'SHIPPED' ? 'text-green-500' : 'text-zinc-400'}`}>
|
||||||
{statusLabels[split.status || ''] || split.status}
|
{statusLabels[split.status || ''] || split.status}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -75,23 +75,23 @@ export default function SplitProgressBar({
|
|||||||
{showLabels && (
|
{showLabels && (
|
||||||
<div className="flex flex-wrap gap-3 text-[10px] font-bold">
|
<div className="flex flex-wrap gap-3 text-[10px] font-bold">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="w-2.5 h-2.5 rounded-sm bg-zinc-600" />
|
<div className="w-2.5 h-2.5 rounded-xs bg-zinc-600" />
|
||||||
<span className="text-zinc-500">Host ({hostShare}cl)</span>
|
<span className="text-zinc-500">Host ({hostShare}cl)</span>
|
||||||
</div>
|
</div>
|
||||||
{taken > 0 && (
|
{taken > 0 && (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="w-2.5 h-2.5 rounded-sm bg-orange-600" />
|
<div className="w-2.5 h-2.5 rounded-xs bg-orange-600" />
|
||||||
<span className="text-zinc-500">Vergeben ({taken}cl)</span>
|
<span className="text-zinc-500">Vergeben ({taken}cl)</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{reserved > 0 && (
|
{reserved > 0 && (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="w-2.5 h-2.5 rounded-sm bg-yellow-500" />
|
<div className="w-2.5 h-2.5 rounded-xs bg-yellow-500" />
|
||||||
<span className="text-zinc-500">Reserviert ({reserved}cl)</span>
|
<span className="text-zinc-500">Reserviert ({reserved}cl)</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="w-2.5 h-2.5 rounded-sm bg-green-500" />
|
<div className="w-2.5 h-2.5 rounded-xs bg-green-500" />
|
||||||
<span className="text-zinc-500">Verfügbar ({available}cl)</span>
|
<span className="text-zinc-500">Verfügbar ({available}cl)</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
|
|||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
placeholder="Tag suchen oder hinzufügen..."
|
placeholder="Tag suchen oder hinzufügen..."
|
||||||
className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-2xl text-[11px] font-medium focus:ring-1 focus:ring-orange-600/50 focus:border-orange-600/50 outline-none transition-all text-zinc-200 placeholder:text-zinc-600"
|
className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-2xl text-[11px] font-medium focus:ring-1 focus:ring-orange-600/50 focus:border-orange-600/50 outline-hidden transition-all text-zinc-200 placeholder:text-zinc-600"
|
||||||
/>
|
/>
|
||||||
{isCreating && (
|
{isCreating && (
|
||||||
<Loader2 className="absolute right-3.5 animate-spin text-orange-600" size={14} />
|
<Loader2 className="absolute right-3.5 animate-spin text-orange-600" size={14} />
|
||||||
@@ -150,7 +150,7 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
|
|||||||
key={tag.id}
|
key={tag.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onToggleTag(tag.id)}
|
onClick={() => onToggleTag(tag.id)}
|
||||||
className="px-3 py-1.5 rounded-xl bg-orange-950/20 text-orange-500 text-[10px] font-black uppercase tracking-tight hover:bg-orange-600 hover:text-white transition-all border border-orange-600/20 flex items-center gap-1.5 shadow-sm"
|
className="px-3 py-1.5 rounded-xl bg-orange-950/20 text-orange-500 text-[10px] font-black uppercase tracking-tight hover:bg-orange-600 hover:text-white transition-all border border-orange-600/20 flex items-center gap-1.5 shadow-xs"
|
||||||
>
|
>
|
||||||
<Sparkles size={10} />
|
<Sparkles size={10} />
|
||||||
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}
|
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
|||||||
const [selectedBuddyIds, setSelectedBuddyIds] = useState<string[]>([]);
|
const [selectedBuddyIds, setSelectedBuddyIds] = useState<string[]>([]);
|
||||||
const [bottlePurchasePrice, setBottlePurchasePrice] = useState(bottleMetadata.purchase_price?.toString() || '');
|
const [bottlePurchasePrice, setBottlePurchasePrice] = useState(bottleMetadata.purchase_price?.toString() || '');
|
||||||
|
|
||||||
|
// Guessing State (Blind Mode)
|
||||||
|
const [guessAbv, setGuessAbv] = useState<string>('');
|
||||||
|
const [guessAge, setGuessAge] = useState<string>('');
|
||||||
|
const [guessRegion, setGuessRegion] = useState<string>('');
|
||||||
|
const [isSessionBlind, setIsSessionBlind] = useState(false);
|
||||||
|
|
||||||
// Section collapse states
|
// Section collapse states
|
||||||
const [isNoseExpanded, setIsNoseExpanded] = useState(defaultExpanded);
|
const [isNoseExpanded, setIsNoseExpanded] = useState(defaultExpanded);
|
||||||
const [isPalateExpanded, setIsPalateExpanded] = useState(defaultExpanded);
|
const [isPalateExpanded, setIsPalateExpanded] = useState(defaultExpanded);
|
||||||
@@ -80,7 +86,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
|||||||
// Track last seen confidence to detect cloud vs local updates
|
// Track last seen confidence to detect cloud vs local updates
|
||||||
const lastConfidenceRef = React.useRef<number>(0);
|
const lastConfidenceRef = React.useRef<number>(0);
|
||||||
|
|
||||||
// Sync bottleMetadata prop changes to internal state (for live Gemini updates)
|
// Sync bottleMetadata prop changes to internal state (for live AI updates)
|
||||||
// Cloud data (confidence >= 0.6 OR >= 60) overrides local OCR (confidence ~50 or ~0.5)
|
// Cloud data (confidence >= 0.6 OR >= 60) overrides local OCR (confidence ~50 or ~0.5)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Normalize confidence to 0-100 scale (Gemini returns 0-1, local returns 0-100)
|
// Normalize confidence to 0-100 scale (Gemini returns 0-1, local returns 0-100)
|
||||||
@@ -143,6 +149,17 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
|||||||
setSelectedBuddyIds(participants.map(p => p.buddy_id));
|
setSelectedBuddyIds(participants.map(p => p.buddy_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if session is blind
|
||||||
|
const { data: sessionData } = await supabase
|
||||||
|
.from('tasting_sessions')
|
||||||
|
.select('is_blind')
|
||||||
|
.eq('id', activeSessionId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (sessionData?.is_blind) {
|
||||||
|
setIsSessionBlind(true);
|
||||||
|
}
|
||||||
|
|
||||||
const { data: lastTastings } = await supabase
|
const { data: lastTastings } = await supabase
|
||||||
.from('tastings')
|
.from('tastings')
|
||||||
.select(`
|
.select(`
|
||||||
@@ -237,6 +254,10 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
|||||||
is_sample: isSample,
|
is_sample: isSample,
|
||||||
buddy_ids: selectedBuddyIds,
|
buddy_ids: selectedBuddyIds,
|
||||||
tag_ids: [...noseTagIds, ...palateTagIds, ...finishTagIds, ...textureTagIds],
|
tag_ids: [...noseTagIds, ...palateTagIds, ...finishTagIds, ...textureTagIds],
|
||||||
|
// Guessing Data
|
||||||
|
guess_abv: guessAbv ? parseFloat(guessAbv) : null,
|
||||||
|
guess_age: guessAge ? parseInt(guessAge) : null,
|
||||||
|
guess_region: guessRegion || null,
|
||||||
// Visual data for ResultCard
|
// Visual data for ResultCard
|
||||||
// Edited bottle metadata
|
// Edited bottle metadata
|
||||||
bottleMetadata: {
|
bottleMetadata: {
|
||||||
@@ -327,60 +348,99 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
|||||||
|
|
||||||
{showBottleDetails && (
|
{showBottleDetails && (
|
||||||
<div className="p-4 pt-0 space-y-3 border-t border-zinc-800/50">
|
<div className="p-4 pt-0 space-y-3 border-t border-zinc-800/50">
|
||||||
|
{/* Helper to check field confidence */}
|
||||||
|
{(() => {
|
||||||
|
const checkConfidence = (field: string) => {
|
||||||
|
const scores = bottleMetadata.confidence_scores;
|
||||||
|
if (!scores) return true; // Default to neutral if no scores
|
||||||
|
const score = scores[field];
|
||||||
|
return score === undefined || score >= 80;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
{/* Name */}
|
{/* Name */}
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
|
<div className="flex justify-between items-center mb-1.5">
|
||||||
|
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block">
|
||||||
Flaschenname
|
Flaschenname
|
||||||
</label>
|
</label>
|
||||||
|
{!checkConfidence('name') && (
|
||||||
|
<span className="flex items-center gap-1 text-[8px] font-black text-yellow-600 uppercase tracking-tighter animate-pulse">
|
||||||
|
<AlertTriangle size={8} /> Unsicher
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={bottleName}
|
value={bottleName}
|
||||||
onChange={(e) => setBottleName(e.target.value)}
|
onChange={(e) => setBottleName(e.target.value)}
|
||||||
placeholder="e.g. 12 Year Old"
|
placeholder="e.g. 12 Year Old"
|
||||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
|
className={`w-full bg-zinc-950 border rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-hidden focus:border-orange-600 transition-all ${!checkConfidence('name') ? 'border-yellow-600/50 shadow-[0_0_10px_rgba(202,138,4,0.1)]' : 'border-zinc-800'
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Distillery */}
|
{/* Distillery */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
|
<div className="flex justify-between items-center mb-1.5">
|
||||||
|
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block">
|
||||||
Destillerie
|
Destillerie
|
||||||
</label>
|
</label>
|
||||||
|
{!checkConfidence('distillery') && (
|
||||||
|
<span className="flex items-center gap-1 text-[8px] font-black text-yellow-600 uppercase tracking-tighter animate-pulse">
|
||||||
|
<AlertTriangle size={8} /> Unsicher
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={bottleDistillery}
|
value={bottleDistillery}
|
||||||
onChange={(e) => setBottleDistillery(e.target.value)}
|
onChange={(e) => setBottleDistillery(e.target.value)}
|
||||||
placeholder="e.g. Lagavulin"
|
placeholder="e.g. Lagavulin"
|
||||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
|
className={`w-full bg-zinc-950 border rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-hidden focus:border-orange-600 transition-all ${!checkConfidence('distillery') ? 'border-yellow-600/50 shadow-[0_0_10px_rgba(202,138,4,0.1)]' : 'border-zinc-800'
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{/* ABV */}
|
{/* ABV */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
|
<div className="flex justify-between items-center mb-1.5">
|
||||||
|
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block">
|
||||||
Alkohol (ABV %)
|
Alkohol (ABV %)
|
||||||
</label>
|
</label>
|
||||||
|
{!checkConfidence('abv') && (
|
||||||
|
<AlertTriangle size={8} className="text-yellow-600 animate-pulse" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
value={bottleAbv}
|
value={bottleAbv}
|
||||||
onChange={(e) => setBottleAbv(e.target.value)}
|
onChange={(e) => setBottleAbv(e.target.value)}
|
||||||
placeholder="43.0"
|
placeholder="43.0"
|
||||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
|
className={`w-full bg-zinc-950 border rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-hidden focus:border-orange-600 transition-all ${!checkConfidence('abv') ? 'border-yellow-600/50 shadow-[0_0_10px_rgba(202,138,4,0.1)]' : 'border-zinc-800'
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* Age */}
|
{/* Age */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
|
<div className="flex justify-between items-center mb-1.5">
|
||||||
|
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block">
|
||||||
Alter (Jahre)
|
Alter (Jahre)
|
||||||
</label>
|
</label>
|
||||||
|
{!checkConfidence('age') && (
|
||||||
|
<AlertTriangle size={8} className="text-yellow-600 animate-pulse" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={bottleAge}
|
value={bottleAge}
|
||||||
onChange={(e) => setBottleAge(e.target.value)}
|
onChange={(e) => setBottleAge(e.target.value)}
|
||||||
placeholder="12"
|
placeholder="12"
|
||||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
|
className={`w-full bg-zinc-950 border rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-hidden focus:border-orange-600 transition-all ${!checkConfidence('age') ? 'border-yellow-600/50 shadow-[0_0_10px_rgba(202,138,4,0.1)]' : 'border-zinc-800'
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -395,22 +455,33 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
|||||||
value={bottleCategory}
|
value={bottleCategory}
|
||||||
onChange={(e) => setBottleCategory(e.target.value)}
|
onChange={(e) => setBottleCategory(e.target.value)}
|
||||||
placeholder="e.g. Single Malt"
|
placeholder="e.g. Single Malt"
|
||||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
|
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-hidden focus:border-orange-600 transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* Cask Type */}
|
{/* Cask Type */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
|
<div className="flex justify-between items-center mb-1.5">
|
||||||
|
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block">
|
||||||
Fass-Typ (Cask)
|
Fass-Typ (Cask)
|
||||||
</label>
|
</label>
|
||||||
|
{!checkConfidence('cask_type') && (
|
||||||
|
<span className="flex items-center gap-1 text-[8px] font-black text-yellow-600 uppercase tracking-tighter animate-pulse">
|
||||||
|
<AlertTriangle size={8} /> Unsicher
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={bottleCaskType}
|
value={bottleCaskType}
|
||||||
onChange={(e) => setBottleCaskType(e.target.value)}
|
onChange={(e) => setBottleCaskType(e.target.value)}
|
||||||
placeholder="e.g. Oloroso Sherry Cask"
|
placeholder="e.g. Oloroso Sherry Cask"
|
||||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
|
className={`w-full bg-zinc-950 border rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-hidden focus:border-orange-600 transition-all ${!checkConfidence('cask_type') ? 'border-yellow-600/50 shadow-[0_0_10px_rgba(202,138,4,0.1)]' : 'border-zinc-800'
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
{/* Vintage */}
|
{/* Vintage */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
|
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
|
||||||
@@ -421,7 +492,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
|||||||
value={bottleVintage}
|
value={bottleVintage}
|
||||||
onChange={(e) => setBottleVintage(e.target.value)}
|
onChange={(e) => setBottleVintage(e.target.value)}
|
||||||
placeholder="e.g. 2007"
|
placeholder="e.g. 2007"
|
||||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
|
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-hidden focus:border-orange-600 transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -435,7 +506,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
|||||||
value={bottleBottler}
|
value={bottleBottler}
|
||||||
onChange={(e) => setBottleBottler(e.target.value)}
|
onChange={(e) => setBottleBottler(e.target.value)}
|
||||||
placeholder="e.g. Independent Bottler"
|
placeholder="e.g. Independent Bottler"
|
||||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
|
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-hidden focus:border-orange-600 transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -449,7 +520,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
|||||||
value={bottleDistilledAt}
|
value={bottleDistilledAt}
|
||||||
onChange={(e) => setBottleDistilledAt(e.target.value)}
|
onChange={(e) => setBottleDistilledAt(e.target.value)}
|
||||||
placeholder="e.g. 2007"
|
placeholder="e.g. 2007"
|
||||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
|
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-hidden focus:border-orange-600 transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -463,7 +534,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
|||||||
value={bottleBottledAt}
|
value={bottleBottledAt}
|
||||||
onChange={(e) => setBottleBottledAt(e.target.value)}
|
onChange={(e) => setBottleBottledAt(e.target.value)}
|
||||||
placeholder="e.g. 2024"
|
placeholder="e.g. 2024"
|
||||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
|
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-hidden focus:border-orange-600 transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -477,7 +548,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
|||||||
value={bottleBatchInfo}
|
value={bottleBatchInfo}
|
||||||
onChange={(e) => setBottleBatchInfo(e.target.value)}
|
onChange={(e) => setBottleBatchInfo(e.target.value)}
|
||||||
placeholder="e.g. Oloroso Sherry Cask"
|
placeholder="e.g. Oloroso Sherry Cask"
|
||||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
|
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-hidden focus:border-orange-600 transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -491,7 +562,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
|||||||
value={bottleCode}
|
value={bottleCode}
|
||||||
onChange={(e) => setBottleCode(e.target.value)}
|
onChange={(e) => setBottleCode(e.target.value)}
|
||||||
placeholder="e.g. WB271235"
|
placeholder="e.g. WB271235"
|
||||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 font-mono focus:outline-none focus:border-orange-600 transition-colors"
|
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 font-mono focus:outline-hidden focus:border-orange-600 transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -504,7 +575,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
|||||||
<select
|
<select
|
||||||
value={status}
|
value={status}
|
||||||
onChange={(e) => setStatus(e.target.value)}
|
onChange={(e) => setStatus(e.target.value)}
|
||||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-orange-600 transition-colors"
|
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 focus:outline-hidden focus:border-orange-600 transition-colors"
|
||||||
>
|
>
|
||||||
<option value="sealed">Versiegelt</option>
|
<option value="sealed">Versiegelt</option>
|
||||||
<option value="open">Offen</option>
|
<option value="open">Offen</option>
|
||||||
@@ -580,6 +651,54 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Blind Guessing Section */}
|
||||||
|
{isSessionBlind && (
|
||||||
|
<div className="bg-purple-900/10 rounded-[32px] p-8 border border-purple-500/30 space-y-8 relative overflow-hidden group">
|
||||||
|
<div className="absolute top-0 right-0 p-8 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||||
|
<Sparkles size={80} className="text-purple-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<h3 className="text-[10px] font-black uppercase tracking-[0.4em] text-purple-400 mb-1">Experimenteller Gaumen</h3>
|
||||||
|
<p className="text-2xl font-black text-white tracking-tighter">Was ist im Glas?</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 relative">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[9px] font-black uppercase tracking-widest text-purple-400/60 ml-1">Geschätzter ABV (%)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={guessAbv}
|
||||||
|
onChange={(e) => setGuessAbv(e.target.value)}
|
||||||
|
placeholder="z.B. 46.3"
|
||||||
|
className="w-full bg-black/40 border border-purple-500/20 rounded-2xl px-5 py-3 text-sm text-white focus:border-purple-500/50 outline-hidden transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[9px] font-black uppercase tracking-widest text-purple-400/60 ml-1">Geschätztes Alter</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={guessAge}
|
||||||
|
onChange={(e) => setGuessAge(e.target.value)}
|
||||||
|
placeholder="z.B. 12"
|
||||||
|
className="w-full bg-black/40 border border-purple-500/20 rounded-2xl px-5 py-3 text-sm text-white focus:border-purple-500/50 outline-hidden transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2 space-y-2">
|
||||||
|
<label className="text-[9px] font-black uppercase tracking-widest text-purple-400/60 ml-1">Region / Destillerie Tipp</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={guessRegion}
|
||||||
|
onChange={(e) => setGuessRegion(e.target.value)}
|
||||||
|
placeholder="z.B. Islay / Lagavulin"
|
||||||
|
className="w-full bg-black/40 border border-purple-500/20 rounded-2xl px-5 py-3 text-sm text-white focus:border-purple-500/50 outline-hidden transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Shared Tasting Form Body */}
|
{/* Shared Tasting Form Body */}
|
||||||
<TastingFormBody
|
<TastingFormBody
|
||||||
rating={rating}
|
rating={rating}
|
||||||
@@ -610,7 +729,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Fixed/Sticky Footer for Save Action */}
|
{/* Fixed/Sticky Footer for Save Action */}
|
||||||
<div className="w-full p-6 bg-gradient-to-t from-zinc-950 via-zinc-950/95 to-transparent border-t border-white/5 shrink-0 z-20">
|
<div className="w-full p-6 bg-linear-to-t from-zinc-950 via-zinc-950/95 to-transparent border-t border-white/5 shrink-0 z-20">
|
||||||
<div className="max-w-2xl mx-auto">
|
<div className="max-w-2xl mx-auto">
|
||||||
<button
|
<button
|
||||||
onClick={handleInternalSave}
|
onClick={handleInternalSave}
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ export default function TastingFormBody({
|
|||||||
onChange={(e) => setNose(e.target.value)}
|
onChange={(e) => setNose(e.target.value)}
|
||||||
placeholder={t('tasting.notesPlaceholder')}
|
placeholder={t('tasting.notesPlaceholder')}
|
||||||
rows={2}
|
rows={2}
|
||||||
className="w-full p-4 bg-zinc-950 border border-zinc-800 rounded-2xl text-sm focus:ring-1 focus:ring-orange-600 outline-none resize-none transition-all text-zinc-200 placeholder:text-zinc-700"
|
className="w-full p-4 bg-zinc-950 border border-zinc-800 rounded-2xl text-sm focus:ring-1 focus:ring-orange-600 outline-hidden resize-none transition-all text-zinc-200 placeholder:text-zinc-700"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -213,7 +213,7 @@ export default function TastingFormBody({
|
|||||||
onChange={(e) => setPalate(e.target.value)}
|
onChange={(e) => setPalate(e.target.value)}
|
||||||
placeholder={t('tasting.notesPlaceholder')}
|
placeholder={t('tasting.notesPlaceholder')}
|
||||||
rows={2}
|
rows={2}
|
||||||
className="w-full p-4 bg-zinc-950 border border-zinc-800 rounded-2xl text-sm focus:ring-1 focus:ring-orange-600 outline-none resize-none transition-all text-zinc-200 placeholder:text-zinc-700"
|
className="w-full p-4 bg-zinc-950 border border-zinc-800 rounded-2xl text-sm focus:ring-1 focus:ring-orange-600 outline-hidden resize-none transition-all text-zinc-200 placeholder:text-zinc-700"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -281,7 +281,7 @@ export default function TastingFormBody({
|
|||||||
onChange={(e) => setFinish(e.target.value)}
|
onChange={(e) => setFinish(e.target.value)}
|
||||||
placeholder={t('tasting.notesPlaceholder')}
|
placeholder={t('tasting.notesPlaceholder')}
|
||||||
rows={2}
|
rows={2}
|
||||||
className="w-full p-4 bg-zinc-950 border border-zinc-800 rounded-2xl text-sm focus:ring-1 focus:ring-orange-600 outline-none resize-none transition-all text-zinc-200 placeholder:text-zinc-700"
|
className="w-full p-4 bg-zinc-950 border border-zinc-800 rounded-2xl text-sm focus:ring-1 focus:ring-orange-600 outline-hidden resize-none transition-all text-zinc-200 placeholder:text-zinc-700"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ export default function TastingHub({ isOpen, onClose }: TastingHubProps) {
|
|||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-[60]"
|
className="fixed inset-0 bg-black/80 backdrop-blur-xs z-60"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
@@ -188,7 +188,7 @@ export default function TastingHub({ isOpen, onClose }: TastingHubProps) {
|
|||||||
animate={{ y: 0 }}
|
animate={{ y: 0 }}
|
||||||
exit={{ y: '100%' }}
|
exit={{ y: '100%' }}
|
||||||
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
||||||
className="fixed bottom-0 left-0 right-0 h-[85vh] bg-[#09090b] border-t border-white/10 rounded-t-[40px] z-[70] flex flex-col shadow-2xl overflow-hidden"
|
className="fixed bottom-0 left-0 right-0 h-[85vh] bg-[#09090b] border-t border-white/10 rounded-t-[40px] z-70 flex flex-col shadow-2xl overflow-hidden"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="p-8 pb-4 flex items-center justify-between shrink-0">
|
<div className="p-8 pb-4 flex items-center justify-between shrink-0">
|
||||||
@@ -242,7 +242,7 @@ export default function TastingHub({ isOpen, onClose }: TastingHubProps) {
|
|||||||
value={newName}
|
value={newName}
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
placeholder={t('hub.placeholders.sessionName')}
|
placeholder={t('hub.placeholders.sessionName')}
|
||||||
className="flex-1 bg-black/40 border border-white/5 rounded-2xl px-6 py-4 text-sm font-bold text-white placeholder:text-zinc-700 focus:outline-none focus:border-orange-600 transition-all ring-inset focus:ring-1 focus:ring-orange-600/50"
|
className="flex-1 bg-black/40 border border-white/5 rounded-2xl px-6 py-4 text-sm font-bold text-white placeholder:text-zinc-700 focus:outline-hidden focus:border-orange-600 transition-all ring-inset focus:ring-1 focus:ring-orange-600/50"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { useI18n } from '@/i18n/I18nContext';
|
|||||||
import { deleteTasting } from '@/services/delete-tasting';
|
import { deleteTasting } from '@/services/delete-tasting';
|
||||||
import { useLiveQuery } from 'dexie-react-hooks';
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
import FlavorRadar from './FlavorRadar';
|
||||||
|
|
||||||
|
|
||||||
interface Tasting {
|
interface Tasting {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -38,6 +40,13 @@ interface Tasting {
|
|||||||
}[];
|
}[];
|
||||||
user_id: string;
|
user_id: string;
|
||||||
isPending?: boolean;
|
isPending?: boolean;
|
||||||
|
flavor_profile?: {
|
||||||
|
smoky: number;
|
||||||
|
fruity: number;
|
||||||
|
spicy: number;
|
||||||
|
sweet: number;
|
||||||
|
floral: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TastingListProps {
|
interface TastingListProps {
|
||||||
@@ -92,7 +101,8 @@ export default function TastingList({ initialTastings, currentUserId, bottleId }
|
|||||||
isPending: true,
|
isPending: true,
|
||||||
tasting_buddies: [],
|
tasting_buddies: [],
|
||||||
tasting_sessions: undefined,
|
tasting_sessions: undefined,
|
||||||
tasting_tags: []
|
tasting_tags: [],
|
||||||
|
flavor_profile: undefined
|
||||||
}))
|
}))
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -139,7 +149,7 @@ export default function TastingList({ initialTastings, currentUserId, bottleId }
|
|||||||
{sortedTastings.map((note) => (
|
{sortedTastings.map((note) => (
|
||||||
<div
|
<div
|
||||||
key={note.id}
|
key={note.id}
|
||||||
className="bg-white dark:bg-zinc-900 p-6 rounded-3xl border border-zinc-200 dark:border-zinc-800 shadow-sm space-y-4 hover:border-amber-500/30 transition-all hover:shadow-md group"
|
className="bg-white dark:bg-zinc-900 p-6 rounded-3xl border border-zinc-200 dark:border-zinc-800 shadow-xs space-y-4 hover:border-amber-500/30 transition-all hover:shadow-md group"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||||
@@ -182,7 +192,7 @@ export default function TastingList({ initialTastings, currentUserId, bottleId }
|
|||||||
<button
|
<button
|
||||||
onClick={() => note.id && note.bottle_id && handleDelete(note.id, note.bottle_id)}
|
onClick={() => note.id && note.bottle_id && handleDelete(note.id, note.bottle_id)}
|
||||||
disabled={!!isDeleting}
|
disabled={!!isDeleting}
|
||||||
className="px-3 py-1.5 text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 rounded-xl transition-all disabled:opacity-50 flex items-center gap-2 border border-red-100 dark:border-red-900/30 font-black text-[10px] uppercase tracking-widest shadow-sm hover:bg-red-600 hover:text-white dark:hover:bg-red-600 dark:hover:text-white"
|
className="px-3 py-1.5 text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 rounded-xl transition-all disabled:opacity-50 flex items-center gap-2 border border-red-100 dark:border-red-900/30 font-black text-[10px] uppercase tracking-widest shadow-xs hover:bg-red-600 hover:text-white dark:hover:bg-red-600 dark:hover:text-white"
|
||||||
title="Tasting löschen"
|
title="Tasting löschen"
|
||||||
>
|
>
|
||||||
{isDeleting === note.id ? (
|
{isDeleting === note.id ? (
|
||||||
@@ -198,7 +208,15 @@ export default function TastingList({ initialTastings, currentUserId, bottleId }
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 relative">
|
<div className={`grid grid-cols-1 ${note.flavor_profile ? 'md:grid-cols-4' : 'md:grid-cols-3'} gap-6 relative`}>
|
||||||
|
{note.flavor_profile && (
|
||||||
|
<div className="md:col-span-1 bg-zinc-950/50 rounded-2xl border border-white/5 p-2 flex flex-col items-center justify-center">
|
||||||
|
<div className="text-[8px] font-black text-zinc-500 uppercase tracking-[0.2em] mb-1">Flavor Profile</div>
|
||||||
|
<FlavorRadar profile={note.flavor_profile} size={140} showAxis={false} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={`${note.flavor_profile ? 'md:col-span-3' : 'md:col-span-3'} grid grid-cols-1 md:grid-cols-3 gap-6 relative`}>
|
||||||
{/* Visual Divider for MD and up */}
|
{/* Visual Divider for MD and up */}
|
||||||
<div className="hidden md:block absolute left-1/3 top-0 bottom-0 w-px bg-zinc-100 dark:bg-zinc-800/50" />
|
<div className="hidden md:block absolute left-1/3 top-0 bottom-0 w-px bg-zinc-100 dark:bg-zinc-800/50" />
|
||||||
<div className="hidden md:block absolute left-2/3 top-0 bottom-0 w-px bg-zinc-100 dark:bg-zinc-800/50" />
|
<div className="hidden md:block absolute left-2/3 top-0 bottom-0 w-px bg-zinc-100 dark:bg-zinc-800/50" />
|
||||||
@@ -228,6 +246,7 @@ export default function TastingList({ initialTastings, currentUserId, bottleId }
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{note.tasting_tags && note.tasting_tags.length > 0 && (
|
{note.tasting_tags && note.tasting_tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5 pt-2">
|
<div className="flex flex-wrap gap-1.5 pt-2">
|
||||||
|
|||||||
@@ -305,7 +305,7 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Sticky Save Button Container */}
|
{/* Sticky Save Button Container */}
|
||||||
<div className="sticky bottom-0 -mx-6 px-6 py-4 bg-gradient-to-t from-zinc-950 via-zinc-950/90 to-transparent z-10">
|
<div className="sticky bottom-0 -mx-6 px-6 py-4 bg-linear-to-t from-zinc-950 via-zinc-950/90 to-transparent z-10">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ export default function UploadQueue() {
|
|||||||
if (totalInQueue === 0) return null;
|
if (totalInQueue === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-24 right-6 md:bottom-6 md:right-6 z-[100] flex flex-col items-end gap-3 translate-y-0">
|
<div className="fixed bottom-24 right-6 md:bottom-6 md:right-6 z-100 flex flex-col items-end gap-3 translate-y-0">
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{!isCollapsed ? (
|
{!isCollapsed ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ export default function UserManagementClient({ initialUsers, plans }: UserManage
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Search Bar */}
|
{/* Search Bar */}
|
||||||
<div className="bg-zinc-900 rounded-[32px] p-6 border border-zinc-800 shadow-sm">
|
<div className="bg-zinc-900 rounded-[32px] p-6 border border-zinc-800 shadow-xs">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" size={20} />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" size={20} />
|
||||||
<input
|
<input
|
||||||
@@ -174,13 +174,13 @@ export default function UserManagementClient({ initialUsers, plans }: UserManage
|
|||||||
placeholder="Search users by email or username..."
|
placeholder="Search users by email or username..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-3 bg-zinc-800 border border-zinc-700 rounded-2xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white"
|
className="w-full pl-10 pr-4 py-3 bg-zinc-800 border border-zinc-700 rounded-2xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User Table */}
|
{/* User Table */}
|
||||||
<div className="bg-zinc-900 rounded-[32px] p-6 border border-zinc-800 shadow-sm overflow-hidden">
|
<div className="bg-zinc-900 rounded-[32px] p-6 border border-zinc-800 shadow-xs overflow-hidden">
|
||||||
<h2 className="text-xl font-bold text-white uppercase tracking-tighter mb-6">Users ({filteredUsers.length})</h2>
|
<h2 className="text-xl font-bold text-white uppercase tracking-tighter mb-6">Users ({filteredUsers.length})</h2>
|
||||||
<div className="overflow-x-auto -mx-6">
|
<div className="overflow-x-auto -mx-6">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
@@ -235,7 +235,7 @@ export default function UserManagementClient({ initialUsers, plans }: UserManage
|
|||||||
|
|
||||||
{/* Edit Modal */}
|
{/* Edit Modal */}
|
||||||
{editingUser && (
|
{editingUser && (
|
||||||
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
<div className="fixed inset-0 bg-black/80 backdrop-blur-xs flex items-center justify-center p-4 z-50">
|
||||||
<div className="bg-zinc-900 rounded-[32px] p-8 max-w-2xl w-full max-h-[90vh] overflow-y-auto border border-zinc-800 shadow-2xl space-y-8">
|
<div className="bg-zinc-900 rounded-[32px] p-8 max-w-2xl w-full max-h-[90vh] overflow-y-auto border border-zinc-800 shadow-2xl space-y-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -278,7 +278,7 @@ export default function UserManagementClient({ initialUsers, plans }: UserManage
|
|||||||
value={creditAmount}
|
value={creditAmount}
|
||||||
onChange={(e) => setCreditAmount(e.target.value)}
|
onChange={(e) => setCreditAmount(e.target.value)}
|
||||||
placeholder="e.g. 100 or -50"
|
placeholder="e.g. 100 or -50"
|
||||||
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white"
|
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -288,7 +288,7 @@ export default function UserManagementClient({ initialUsers, plans }: UserManage
|
|||||||
value={reason}
|
value={reason}
|
||||||
onChange={(e) => setReason(e.target.value)}
|
onChange={(e) => setReason(e.target.value)}
|
||||||
placeholder="e.g. Monthly bonus"
|
placeholder="e.g. Monthly bonus"
|
||||||
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white"
|
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -319,7 +319,7 @@ export default function UserManagementClient({ initialUsers, plans }: UserManage
|
|||||||
<select
|
<select
|
||||||
value={selectedPlan}
|
value={selectedPlan}
|
||||||
onChange={(e) => setSelectedPlan(e.target.value)}
|
onChange={(e) => setSelectedPlan(e.target.value)}
|
||||||
className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white appearance-none"
|
className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white appearance-none"
|
||||||
>
|
>
|
||||||
<option value="">Select a plan...</option>
|
<option value="">Select a plan...</option>
|
||||||
{plans.map(plan => (
|
{plans.map(plan => (
|
||||||
@@ -352,7 +352,7 @@ export default function UserManagementClient({ initialUsers, plans }: UserManage
|
|||||||
value={dailyLimit}
|
value={dailyLimit}
|
||||||
onChange={(e) => setDailyLimit(e.target.value)}
|
onChange={(e) => setDailyLimit(e.target.value)}
|
||||||
placeholder="Global (80)"
|
placeholder="Global (80)"
|
||||||
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white"
|
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -361,7 +361,7 @@ export default function UserManagementClient({ initialUsers, plans }: UserManage
|
|||||||
type="number"
|
type="number"
|
||||||
value={googleCost}
|
value={googleCost}
|
||||||
onChange={(e) => setGoogleCost(e.target.value)}
|
onChange={(e) => setGoogleCost(e.target.value)}
|
||||||
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white"
|
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -370,7 +370,7 @@ export default function UserManagementClient({ initialUsers, plans }: UserManage
|
|||||||
type="number"
|
type="number"
|
||||||
value={geminiCost}
|
value={geminiCost}
|
||||||
onChange={(e) => setGeminiCost(e.target.value)}
|
onChange={(e) => setGeminiCost(e.target.value)}
|
||||||
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white"
|
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
13
src/config/features.ts
Normal file
13
src/config/features.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export const FEATURES = {
|
||||||
|
// Global Toggle: Set to false to disable download and processing completely
|
||||||
|
ENABLE_AI_BG_REMOVAL: false,
|
||||||
|
|
||||||
|
// Feathering intensity in pixels (1-3px is usually best for bottles)
|
||||||
|
BG_REMOVAL_FEATHER_AMOUNT: 2,
|
||||||
|
|
||||||
|
// Enable cascade OCR (Native TextDetector → RegEx → Fuzzy Match → window.ai)
|
||||||
|
ENABLE_CASCADE_OCR: true,
|
||||||
|
|
||||||
|
// Enable Smart Scan Flow (Native TextDetector on Android, Live Text fallback on iOS)
|
||||||
|
ENABLE_SMART_SCAN: true,
|
||||||
|
};
|
||||||
152
src/hooks/useImageProcessor.ts
Normal file
152
src/hooks/useImageProcessor.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { FEATURES } from '../config/features';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload processed image to Supabase and update bottle record
|
||||||
|
*/
|
||||||
|
async function uploadToSupabase(bottleId: string, blob: Blob): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const supabase = createClient();
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.warn('[ImageProcessor] No user session, skipping Supabase upload');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload to storage
|
||||||
|
const fileName = `${user.id}/${bottleId}_nobg_${uuidv4()}.png`;
|
||||||
|
const { error: uploadError } = await supabase.storage
|
||||||
|
.from('bottles')
|
||||||
|
.upload(fileName, blob, {
|
||||||
|
contentType: 'image/png',
|
||||||
|
upsert: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (uploadError) {
|
||||||
|
console.error('[ImageProcessor] Upload error:', uploadError);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get public URL
|
||||||
|
const { data: { publicUrl } } = supabase.storage
|
||||||
|
.from('bottles')
|
||||||
|
.getPublicUrl(fileName);
|
||||||
|
|
||||||
|
// Update bottle record with new image URL
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from('bottles')
|
||||||
|
.update({ image_url: publicUrl })
|
||||||
|
.eq('id', bottleId);
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
console.error('[ImageProcessor] DB update error:', updateError);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[ImageProcessor] Uploaded to Supabase: ${bottleId}`);
|
||||||
|
return publicUrl;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ImageProcessor] Supabase sync failed:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useImageProcessor() {
|
||||||
|
const workerRef = useRef<Worker | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!FEATURES.ENABLE_AI_BG_REMOVAL) return;
|
||||||
|
|
||||||
|
// Initialize worker
|
||||||
|
console.log('[ImageProcessor] Initializing worker...');
|
||||||
|
const worker = new Worker(
|
||||||
|
'/bg-processor.worker.js',
|
||||||
|
{ type: 'module' }
|
||||||
|
);
|
||||||
|
workerRef.current = worker;
|
||||||
|
console.log('[ImageProcessor] Worker instance created');
|
||||||
|
|
||||||
|
worker.postMessage({ type: 'ping' });
|
||||||
|
console.log('[ImageProcessor] Sent ping to worker');
|
||||||
|
|
||||||
|
worker.onmessage = async (e) => {
|
||||||
|
if (e.data.type === 'pong') {
|
||||||
|
console.log('[ImageProcessor] Received pong from worker - Communication OK');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id, status, blob, error } = e.data;
|
||||||
|
|
||||||
|
if (status === 'success' && blob) {
|
||||||
|
// Convert blob to Base64 for IndexedDB storage
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = async () => {
|
||||||
|
const base64data = reader.result as string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Update cache_bottles if it exists there
|
||||||
|
const cachedBottle = await db.cache_bottles.get(id);
|
||||||
|
if (cachedBottle) {
|
||||||
|
await db.cache_bottles.update(id, {
|
||||||
|
image_url: base64data,
|
||||||
|
bgRemoved: true,
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
});
|
||||||
|
console.log(`[ImageProcessor] Background removed for cached bottle: ${id}`);
|
||||||
|
|
||||||
|
// Upload to Supabase (fire and forget, don't block UI)
|
||||||
|
uploadToSupabase(id, blob).then(url => {
|
||||||
|
if (url) {
|
||||||
|
// Update local cache with the new Supabase URL
|
||||||
|
db.cache_bottles.update(id, { image_url: url });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update pending_scans if it exists there by temp_id
|
||||||
|
const pendingScan = await db.pending_scans.where('temp_id').equals(id).first();
|
||||||
|
if (pendingScan) {
|
||||||
|
await db.pending_scans.update(pendingScan.id!, {
|
||||||
|
imageBase64: base64data,
|
||||||
|
bgRemoved: true
|
||||||
|
});
|
||||||
|
console.log(`[ImageProcessor] Background removed for pending scan: ${id}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ImageProcessor] Failed to update DB:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
} else if (status === 'error') {
|
||||||
|
console.error('[ImageProcessor] Worker error:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
worker.terminate();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addToQueue = useCallback(async (id: string, imageBlob: Blob) => {
|
||||||
|
if (!FEATURES.ENABLE_AI_BG_REMOVAL || !workerRef.current) {
|
||||||
|
console.warn('[ImageProcessor] Background removal disabled or worker not ready');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already processed to avoid redundant work
|
||||||
|
const cached = await db.cache_bottles.get(id);
|
||||||
|
if (cached?.bgRemoved) return;
|
||||||
|
|
||||||
|
const pending = await db.pending_scans.where('temp_id').equals(id).first();
|
||||||
|
if (pending?.bgRemoved) return;
|
||||||
|
|
||||||
|
workerRef.current.postMessage({ id, imageBlob });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { addToQueue };
|
||||||
|
}
|
||||||
70
src/hooks/useOptimistic.ts
Normal file
70
src/hooks/useOptimistic.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useOptimistic, useTransition } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for optimistic updates with automatic rollback on error.
|
||||||
|
* Uses React 19's useOptimistic + startTransition pattern.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { optimisticData, isPending, mutate } = useOptimisticMutation(
|
||||||
|
* tastings,
|
||||||
|
* async (newTasting) => await saveTasting(newTasting)
|
||||||
|
* );
|
||||||
|
*/
|
||||||
|
export function useOptimisticMutation<T, TInput>(
|
||||||
|
initialData: T[],
|
||||||
|
mutationFn: (input: TInput) => Promise<{ success: boolean; error?: string }>
|
||||||
|
) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const [optimisticData, addOptimistic] = useOptimistic<T[], TInput>(
|
||||||
|
initialData,
|
||||||
|
(currentData, newItem) => [...currentData, newItem as unknown as T]
|
||||||
|
);
|
||||||
|
|
||||||
|
const mutate = async (input: TInput, optimisticValue: T) => {
|
||||||
|
startTransition(async () => {
|
||||||
|
// Immediately show optimistic update
|
||||||
|
addOptimistic(input);
|
||||||
|
|
||||||
|
// Perform actual mutation
|
||||||
|
const result = await mutationFn(input);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
console.error('[OptimisticMutation] Failed:', result.error);
|
||||||
|
// Note: React will automatically rollback on error
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
optimisticData,
|
||||||
|
isPending,
|
||||||
|
mutate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple optimistic state for single values (like ratings).
|
||||||
|
*/
|
||||||
|
export function useOptimisticValue<T>(
|
||||||
|
serverValue: T,
|
||||||
|
updateFn: (value: T) => Promise<{ success: boolean }>
|
||||||
|
) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [optimisticValue, setOptimistic] = useOptimistic(serverValue);
|
||||||
|
|
||||||
|
const setValue = (value: T) => {
|
||||||
|
startTransition(async () => {
|
||||||
|
setOptimistic(value);
|
||||||
|
await updateFn(value);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: optimisticValue,
|
||||||
|
isPending,
|
||||||
|
setValue,
|
||||||
|
};
|
||||||
|
}
|
||||||
196
src/hooks/useScanFlow.ts
Normal file
196
src/hooks/useScanFlow.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smart Scan Flow Hook
|
||||||
|
*
|
||||||
|
* "Chamäleon Strategy":
|
||||||
|
* - Branch A (Android/Chrome): Native TextDetector OCR
|
||||||
|
* - Branch B (iOS/Unsupported): System Keyboard with Live Text
|
||||||
|
*
|
||||||
|
* This is separate from the OpenRouter/Gemma cloud workflow.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import '@/types/text-detector.d.ts';
|
||||||
|
|
||||||
|
export interface ScanFlowState {
|
||||||
|
hasNativeOCR: boolean;
|
||||||
|
isIOS: boolean;
|
||||||
|
isAndroid: boolean;
|
||||||
|
isCameraActive: boolean;
|
||||||
|
isFormOpen: boolean;
|
||||||
|
detectedTexts: string[];
|
||||||
|
isProcessing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseScanFlowReturn {
|
||||||
|
state: ScanFlowState;
|
||||||
|
triggerScan: () => void;
|
||||||
|
startCamera: () => void;
|
||||||
|
stopCamera: () => void;
|
||||||
|
openFormWithFocus: () => void;
|
||||||
|
processVideoFrame: (video: HTMLVideoElement) => Promise<string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for smart scan flow with device capability detection
|
||||||
|
*/
|
||||||
|
export function useScanFlow(): UseScanFlowReturn {
|
||||||
|
// Feature detection (run once on mount)
|
||||||
|
const [state, setState] = useState<ScanFlowState>({
|
||||||
|
hasNativeOCR: false,
|
||||||
|
isIOS: false,
|
||||||
|
isAndroid: false,
|
||||||
|
isCameraActive: false,
|
||||||
|
isFormOpen: false,
|
||||||
|
detectedTexts: [],
|
||||||
|
isProcessing: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const detectorRef = useRef<InstanceType<NonNullable<typeof window.TextDetector>> | null>(null);
|
||||||
|
|
||||||
|
// Initialize feature detection
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const hasNativeOCR = 'TextDetector' in window;
|
||||||
|
const ua = navigator.userAgent;
|
||||||
|
const isIOS = /iPad|iPhone|iPod/.test(ua) && !(window as any).MSStream;
|
||||||
|
const isAndroid = /Android/.test(ua);
|
||||||
|
|
||||||
|
console.log('[ScanFlow] Feature Detection:', {
|
||||||
|
hasNativeOCR,
|
||||||
|
isIOS,
|
||||||
|
isAndroid,
|
||||||
|
userAgent: ua.substring(0, 50) + '...',
|
||||||
|
});
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
hasNativeOCR,
|
||||||
|
isIOS,
|
||||||
|
isAndroid,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Initialize TextDetector if available
|
||||||
|
if (hasNativeOCR && window.TextDetector) {
|
||||||
|
try {
|
||||||
|
detectorRef.current = new window.TextDetector();
|
||||||
|
console.log('[ScanFlow] TextDetector initialized');
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[ScanFlow] Failed to initialize TextDetector:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main trigger function - "Chamäleon Strategy"
|
||||||
|
*/
|
||||||
|
const triggerScan = useCallback(() => {
|
||||||
|
if (state.hasNativeOCR) {
|
||||||
|
// PATH A: Android / Chrome (Automated OCR)
|
||||||
|
console.log('[ScanFlow] Branch A: Starting Native TextDetector...');
|
||||||
|
setState(prev => ({ ...prev, isCameraActive: true, isFormOpen: false }));
|
||||||
|
} else {
|
||||||
|
// PATH B: iOS / Fallback (System Keyboard)
|
||||||
|
console.log('[ScanFlow] Branch B: Native OCR missing. Fallback to System Keyboard Flow.');
|
||||||
|
openFormWithFocus();
|
||||||
|
}
|
||||||
|
}, [state.hasNativeOCR]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start camera for native OCR
|
||||||
|
*/
|
||||||
|
const startCamera = useCallback(() => {
|
||||||
|
console.log('[ScanFlow] Starting camera...');
|
||||||
|
setState(prev => ({ ...prev, isCameraActive: true }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop camera
|
||||||
|
*/
|
||||||
|
const stopCamera = useCallback(() => {
|
||||||
|
console.log('[ScanFlow] Stopping camera...');
|
||||||
|
setState(prev => ({ ...prev, isCameraActive: false, detectedTexts: [] }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open form and auto-focus first input (iOS Live Text path)
|
||||||
|
*/
|
||||||
|
const openFormWithFocus = useCallback(() => {
|
||||||
|
console.log('[ScanFlow] Opening form with auto-focus...');
|
||||||
|
setState(prev => ({ ...prev, isFormOpen: true, isCameraActive: false }));
|
||||||
|
|
||||||
|
// UX Hack: Focus the field after a micro-task to ensure Modal is rendered
|
||||||
|
setTimeout(() => {
|
||||||
|
const inputField = document.querySelector('#field-bottle-name') as HTMLInputElement;
|
||||||
|
if (inputField) {
|
||||||
|
inputField.focus();
|
||||||
|
console.log('[ScanFlow] Focused #field-bottle-name for iOS Live Text');
|
||||||
|
} else {
|
||||||
|
// Fallback to any input with data-scan-target
|
||||||
|
const fallback = document.querySelector('[data-scan-target="true"]') as HTMLInputElement;
|
||||||
|
if (fallback) {
|
||||||
|
fallback.focus();
|
||||||
|
console.log('[ScanFlow] Focused fallback scan target');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a video frame using TextDetector
|
||||||
|
*/
|
||||||
|
const processVideoFrame = useCallback(async (video: HTMLVideoElement): Promise<string[]> => {
|
||||||
|
if (!detectorRef.current) {
|
||||||
|
console.warn('[ScanFlow] TextDetector not available');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(prev => ({ ...prev, isProcessing: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const imageBitmap = await createImageBitmap(video);
|
||||||
|
const detections = await detectorRef.current.detect(imageBitmap);
|
||||||
|
|
||||||
|
const texts = detections
|
||||||
|
.map(d => d.rawValue)
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter(text => text.length >= 2); // Filter very short strings
|
||||||
|
|
||||||
|
console.log('[ScanFlow] Detected texts:', texts);
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
detectedTexts: texts,
|
||||||
|
isProcessing: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return texts;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ScanFlow] Frame processing error:', err);
|
||||||
|
setState(prev => ({ ...prev, isProcessing: false }));
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
triggerScan,
|
||||||
|
startCamera,
|
||||||
|
stopCamera,
|
||||||
|
openFormWithFocus,
|
||||||
|
processVideoFrame,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility: Get placeholder text based on device
|
||||||
|
*/
|
||||||
|
export function getScanPlaceholder(isIOS: boolean, defaultText: string = 'Bottle Name'): string {
|
||||||
|
if (isIOS) {
|
||||||
|
return "Tap here & use 'Scan Text' 📷";
|
||||||
|
}
|
||||||
|
return defaultText;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user