Compare commits
24 Commits
d109dfad0e
...
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 |
1
.vscode/settings.json
vendored
Normal file
1
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -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,6 +15,7 @@
|
|||||||
"@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",
|
||||||
@@ -43,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",
|
||||||
@@ -50,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"
|
||||||
},
|
},
|
||||||
|
|||||||
2250
pnpm-lock.yaml
generated
2250
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: {},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
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' }
|
||||||
|
});
|
||||||
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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();
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -49,7 +48,7 @@ export default async function OcrLogsPage() {
|
|||||||
|
|
||||||
{/* Stats Cards */}
|
{/* 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">
|
||||||
<Camera size={20} className="text-blue-600 dark:text-blue-400" />
|
<Camera size={20} className="text-blue-600 dark:text-blue-400" />
|
||||||
@@ -60,7 +59,7 @@ export default async function OcrLogsPage() {
|
|||||||
<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" />
|
||||||
@@ -71,7 +70,7 @@ export default async function OcrLogsPage() {
|
|||||||
<div className="text-xs text-zinc-500 mt-1">Scans today</div>
|
<div className="text-xs text-zinc-500 mt-1">Scans today</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">
|
||||||
<Percent size={20} className="text-amber-600 dark:text-amber-400" />
|
<Percent size={20} className="text-amber-600 dark:text-amber-400" />
|
||||||
@@ -82,7 +81,7 @@ export default async function OcrLogsPage() {
|
|||||||
<div className="text-xs text-zinc-500 mt-1">Recognition quality</div>
|
<div className="text-xs text-zinc-500 mt-1">Recognition quality</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">
|
||||||
<TrendingUp size={20} className="text-purple-600 dark:text-purple-400" />
|
<TrendingUp size={20} className="text-purple-600 dark:text-purple-400" />
|
||||||
@@ -100,7 +99,7 @@ export default async function OcrLogsPage() {
|
|||||||
|
|
||||||
{/* Top Distilleries */}
|
{/* Top Distilleries */}
|
||||||
{stats.topDistilleries.length > 0 && (
|
{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-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">Most Scanned Distilleries</h2>
|
<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">
|
<div className="flex flex-wrap gap-2">
|
||||||
{stats.topDistilleries.map((d, i) => (
|
{stats.topDistilleries.map((d, i) => (
|
||||||
@@ -119,7 +118,7 @@ export default async function OcrLogsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* OCR Logs Grid */}
|
{/* OCR Logs Grid */}
|
||||||
<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 OCR Scans</h2>
|
<h2 className="text-xl font-black text-zinc-900 dark:text-white mb-4">Recent OCR Scans</h2>
|
||||||
|
|
||||||
{logs.length === 0 ? (
|
{logs.length === 0 ? (
|
||||||
@@ -136,7 +135,7 @@ export default async function OcrLogsPage() {
|
|||||||
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"
|
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 */}
|
{/* Image Preview */}
|
||||||
<div className="relative aspect-[4/3] rounded-lg overflow-hidden bg-zinc-200 dark:bg-zinc-700 mb-3">
|
<div className="relative aspect-4/3 rounded-lg overflow-hidden bg-zinc-200 dark:bg-zinc-700 mb-3">
|
||||||
{log.image_thumbnail ? (
|
{log.image_thumbnail ? (
|
||||||
<img
|
<img
|
||||||
src={log.image_thumbnail}
|
src={log.image_thumbnail}
|
||||||
@@ -175,7 +174,7 @@ export default async function OcrLogsPage() {
|
|||||||
{log.distillery}
|
{log.distillery}
|
||||||
</span>
|
</span>
|
||||||
{log.distillery_source && (
|
{log.distillery_source && (
|
||||||
<span className="text-[10px] px-1.5 py-0.5 bg-zinc-200 dark:bg-zinc-700 rounded text-zinc-500">
|
<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}
|
{log.distillery_source}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -190,22 +189,22 @@ export default async function OcrLogsPage() {
|
|||||||
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{log.abv && (
|
{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 text-[10px] font-bold">
|
<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}%
|
{log.abv}%
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{log.age && (
|
{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 text-[10px] font-bold">
|
<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
|
{log.age}y
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{log.vintage && (
|
{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 text-[10px] font-bold">
|
<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}
|
{log.vintage}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{log.volume && (
|
{log.volume && (
|
||||||
<span className="px-2 py-0.5 bg-zinc-100 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-400 rounded text-[10px] font-bold">
|
<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}
|
{log.volume}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -218,7 +217,7 @@ export default async function OcrLogsPage() {
|
|||||||
<summary className="text-[10px] font-bold text-zinc-400 cursor-pointer hover:text-orange-500 uppercase">
|
<summary className="text-[10px] font-bold text-zinc-400 cursor-pointer hover:text-orange-500 uppercase">
|
||||||
Raw Text
|
Raw Text
|
||||||
</summary>
|
</summary>
|
||||||
<pre className="mt-2 p-2 bg-zinc-100 dark:bg-zinc-900 rounded text-[9px] text-zinc-500 overflow-x-auto max-h-20 whitespace-pre-wrap">
|
<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}
|
{log.raw_text}
|
||||||
</pre>
|
</pre>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -117,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"
|
||||||
@@ -128,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" />
|
||||||
@@ -139,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" />
|
||||||
@@ -158,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" />
|
||||||
@@ -169,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" />
|
||||||
@@ -182,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) => (
|
||||||
@@ -205,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}
|
||||||
@@ -266,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>
|
||||||
@@ -280,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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,7 @@ 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 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' });
|
||||||
|
|
||||||
@@ -49,7 +50,9 @@ 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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -308,7 +308,7 @@ export default function SessionDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-[var(--background)] p-4 md:p-12 lg:p-24 pb-32">
|
<main className="min-h-screen bg-(--background) p-4 md:p-12 lg:p-24 pb-32">
|
||||||
<div className="max-w-6xl mx-auto space-y-12">
|
<div className="max-w-6xl mx-auto space-y-12">
|
||||||
{/* Back Link & Info */}
|
{/* Back Link & Info */}
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
@@ -337,7 +337,7 @@ export default function SessionDetailPage() {
|
|||||||
{/* Immersive Header */}
|
{/* Immersive Header */}
|
||||||
<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">
|
<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">
|
||||||
{/* Background Visuals */}
|
{/* Background Visuals */}
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-zinc-900 via-zinc-900 to-black z-0" />
|
<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-2/3 h-full opacity-30 z-0">
|
<div className="absolute top-0 right-0 w-2/3 h-full opacity-30 z-0">
|
||||||
<div
|
<div
|
||||||
@@ -570,7 +570,7 @@ export default function SessionDetailPage() {
|
|||||||
if (e.target.value) handleAddParticipant(e.target.value);
|
if (e.target.value) handleAddParticipant(e.target.value);
|
||||||
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-none focus:border-orange-500/50 transition-colors appearance-none"
|
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' }}
|
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>
|
<option value="">Auswahl...</option>
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ export default function ActiveSessionBanner() {
|
|||||||
initial={{ y: 50, opacity: 0, x: '-50%' }}
|
initial={{ y: 50, opacity: 0, x: '-50%' }}
|
||||||
animate={{ y: 0, opacity: 1, x: '-50%' }}
|
animate={{ y: 0, opacity: 1, x: '-50%' }}
|
||||||
exit={{ y: 50, opacity: 0, 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"
|
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">
|
<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 */}
|
{/* Session Info Link */}
|
||||||
|
|||||||
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}
|
||||||
|
|||||||
@@ -98,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
|
||||||
@@ -110,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}
|
||||||
@@ -121,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 */}
|
||||||
@@ -134,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">
|
||||||
|
|||||||
@@ -37,10 +37,10 @@ function BottleCard({ bottle, sessionId }: BottleCardProps) {
|
|||||||
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-900 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]"
|
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]"
|
||||||
>
|
>
|
||||||
{/* === SPOTIFY-STYLE IMAGE SECTION === */}
|
{/* === SPOTIFY-STYLE IMAGE SECTION === */}
|
||||||
<div className="relative aspect-[3/4] overflow-hidden">
|
<div className="relative aspect-3/4 overflow-hidden">
|
||||||
|
|
||||||
{/* Layer 1: Blurred Backdrop */}
|
{/* Layer 1: Blurred Backdrop */}
|
||||||
<div className="absolute inset-0 z-0">
|
<div className="absolute inset-0 z-0">
|
||||||
@@ -103,10 +103,10 @@ function BottleCard({ bottle, sessionId }: BottleCardProps) {
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 mt-3">
|
<div className="flex flex-wrap gap-2 mt-3">
|
||||||
<span className="px-2 py-1 bg-white/10 backdrop-blur-sm text-zinc-300 text-[9px] font-bold uppercase tracking-widest rounded-md">
|
<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)}
|
{shortenCategory(bottle.category)}
|
||||||
</span>
|
</span>
|
||||||
<span className="px-2 py-1 bg-white/10 backdrop-blur-sm text-zinc-300 text-[9px] font-bold uppercase tracking-widest rounded-md">
|
<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
|
{bottle.abv}% VOL
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -216,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
|
||||||
@@ -232,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>
|
||||||
|
|||||||
@@ -473,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)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
|||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
value={formData.abv}
|
value={formData.abv}
|
||||||
onChange={(e) => setFormData({ ...formData, abv: 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"
|
placeholder="e.g. 46.3"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -160,7 +160,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
|||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={formData.age}
|
value={formData.age}
|
||||||
onChange={(e) => setFormData({ ...formData, age: 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"
|
placeholder="e.g. 12"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -176,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">
|
||||||
@@ -187,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>
|
||||||
@@ -202,7 +202,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
|
|||||||
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')}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ export default function NativeOCRScanner({
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-black">
|
<div className="fixed inset-0 z-50 bg-black">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="absolute top-0 left-0 right-0 z-10 flex items-center justify-between p-4 bg-gradient-to-b from-black/80 to-transparent">
|
<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">
|
<div className="flex items-center gap-2 text-white">
|
||||||
<Zap size={20} className="text-orange-500" />
|
<Zap size={20} className="text-orange-500" />
|
||||||
<span className="font-bold text-sm">Native OCR</span>
|
<span className="font-bold text-sm">Native OCR</span>
|
||||||
@@ -236,7 +236,7 @@ export default function NativeOCRScanner({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Detected Text Display */}
|
{/* Detected Text Display */}
|
||||||
<div className="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/90 to-transparent">
|
<div className="absolute bottom-0 left-0 right-0 p-4 bg-linear-to-t from-black/90 to-transparent">
|
||||||
{extractedData.distillery && (
|
{extractedData.distillery && (
|
||||||
<div className="mb-2 px-3 py-1 bg-orange-600 rounded-full inline-block">
|
<div className="mb-2 px-3 py-1 bg-orange-600 rounded-full inline-block">
|
||||||
<span className="text-white text-sm font-bold">
|
<span className="text-white text-sm font-bold">
|
||||||
@@ -247,12 +247,12 @@ export default function NativeOCRScanner({
|
|||||||
|
|
||||||
<div className="flex gap-2 flex-wrap mb-2">
|
<div className="flex gap-2 flex-wrap mb-2">
|
||||||
{extractedData.abv && (
|
{extractedData.abv && (
|
||||||
<span className="px-2 py-1 bg-white/20 rounded text-white text-xs">
|
<span className="px-2 py-1 bg-white/20 rounded-sm text-white text-xs">
|
||||||
{extractedData.abv}% ABV
|
{extractedData.abv}% ABV
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{extractedData.age && (
|
{extractedData.age && (
|
||||||
<span className="px-2 py-1 bg-white/20 rounded text-white text-xs">
|
<span className="px-2 py-1 bg-white/20 rounded-sm text-white text-xs">
|
||||||
{extractedData.age} Years
|
{extractedData.age} Years
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -110,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 */}
|
||||||
|
|||||||
@@ -354,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>
|
||||||
@@ -520,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;
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -201,7 +201,7 @@ export default function SessionList() {
|
|||||||
<div
|
<div
|
||||||
key={session.id}
|
key={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
|
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-500/[0.03] border-orange-500/40 shadow-[0_0_40px_rgba(234,88,12,0.1)]'
|
? 'bg-orange-500/3 border-orange-500/40 shadow-[0_0_40px_rgba(234,88,12,0.1)]'
|
||||||
: 'bg-zinc-950/50 border-white/5 hover:border-white/10'
|
: 'bg-zinc-950/50 border-white/5 hover:border-white/10'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -289,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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -67,11 +67,11 @@ export default function SessionTimeline({ tastings, sessionStart, isBlind, isRev
|
|||||||
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 && showDetails ? '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 && showDetails && <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">
|
||||||
@@ -92,7 +92,7 @@ export default function SessionTimeline({ tastings, sessionStart, isBlind, isRev
|
|||||||
{displayName}
|
{displayName}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm font-bold text-zinc-100 bg-zinc-800/30 blur-[4px] px-2 py-0.5 rounded-md select-none">
|
<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
|
Unknown Bottle
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
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}
|
||||||
|
|||||||
@@ -376,7 +376,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
|||||||
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 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none 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'
|
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>
|
||||||
@@ -398,7 +398,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
|||||||
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 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none 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'
|
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>
|
||||||
@@ -420,7 +420,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
|||||||
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 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none 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'
|
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>
|
||||||
@@ -439,7 +439,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
|||||||
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 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none 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'
|
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>
|
||||||
@@ -455,7 +455,7 @@ 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 */}
|
||||||
@@ -475,7 +475,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
|||||||
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 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none 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'
|
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>
|
||||||
@@ -492,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>
|
||||||
|
|
||||||
@@ -506,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>
|
||||||
|
|
||||||
@@ -520,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>
|
||||||
|
|
||||||
@@ -534,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>
|
||||||
|
|
||||||
@@ -548,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>
|
||||||
|
|
||||||
@@ -562,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>
|
||||||
|
|
||||||
@@ -575,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>
|
||||||
@@ -672,7 +672,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
|||||||
value={guessAbv}
|
value={guessAbv}
|
||||||
onChange={(e) => setGuessAbv(e.target.value)}
|
onChange={(e) => setGuessAbv(e.target.value)}
|
||||||
placeholder="z.B. 46.3"
|
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-none transition-all"
|
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 className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -682,7 +682,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
|||||||
value={guessAge}
|
value={guessAge}
|
||||||
onChange={(e) => setGuessAge(e.target.value)}
|
onChange={(e) => setGuessAge(e.target.value)}
|
||||||
placeholder="z.B. 12"
|
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-none transition-all"
|
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 className="md:col-span-2 space-y-2">
|
<div className="md:col-span-2 space-y-2">
|
||||||
@@ -692,7 +692,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
|||||||
value={guessRegion}
|
value={guessRegion}
|
||||||
onChange={(e) => setGuessRegion(e.target.value)}
|
onChange={(e) => setGuessRegion(e.target.value)}
|
||||||
placeholder="z.B. Islay / Lagavulin"
|
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-none transition-all"
|
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>
|
</div>
|
||||||
@@ -729,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"
|
||||||
|
|||||||
@@ -149,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">
|
||||||
@@ -192,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 ? (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -18,19 +18,28 @@ const translations: Record<Locale, TranslationKeys> = { de, en };
|
|||||||
const I18nContext = createContext<I18nContextType | undefined>(undefined);
|
const I18nContext = createContext<I18nContextType | undefined>(undefined);
|
||||||
|
|
||||||
export const I18nProvider = ({ children }: { children: ReactNode }) => {
|
export const I18nProvider = ({ children }: { children: ReactNode }) => {
|
||||||
const [locale, setLocaleState] = useState<Locale>('de');
|
const [locale, setLocaleState] = useState<Locale>('en');
|
||||||
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Only run on client side
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
// Check for saved preference first
|
||||||
const savedLocale = localStorage.getItem('locale') as Locale;
|
const savedLocale = localStorage.getItem('locale') as Locale;
|
||||||
if (savedLocale && (savedLocale === 'de' || savedLocale === 'en')) {
|
if (savedLocale && (savedLocale === 'de' || savedLocale === 'en')) {
|
||||||
setLocaleState(savedLocale);
|
setLocaleState(savedLocale);
|
||||||
} else {
|
} else {
|
||||||
// Try to detect browser language
|
// Auto-detect from browser: default to English, switch to German if detected
|
||||||
const browserLang = navigator.language.split('-')[0];
|
const browserLang = navigator.language?.toLowerCase() || 'en';
|
||||||
if (browserLang === 'en') {
|
if (browserLang.startsWith('de')) {
|
||||||
setLocaleState('en');
|
setLocaleState('de');
|
||||||
|
localStorage.setItem('locale', 'de');
|
||||||
|
} else {
|
||||||
|
localStorage.setItem('locale', 'en');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
setIsInitialized(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setLocale = (newLocale: Locale) => {
|
const setLocale = (newLocale: Locale) => {
|
||||||
|
|||||||
@@ -199,6 +199,10 @@ export const de: TranslationKeys = {
|
|||||||
activity: 'Aktivität',
|
activity: 'Aktivität',
|
||||||
search: 'Suchen',
|
search: 'Suchen',
|
||||||
profile: 'Profil',
|
profile: 'Profil',
|
||||||
|
sessions: 'Tastings',
|
||||||
|
buddies: 'Buddies',
|
||||||
|
stats: 'Statistik',
|
||||||
|
wishlist: 'Wunschliste',
|
||||||
},
|
},
|
||||||
hub: {
|
hub: {
|
||||||
title: 'Activity Hub',
|
title: 'Activity Hub',
|
||||||
|
|||||||
@@ -199,6 +199,10 @@ export const en: TranslationKeys = {
|
|||||||
activity: 'Activity',
|
activity: 'Activity',
|
||||||
search: 'Search',
|
search: 'Search',
|
||||||
profile: 'Profile',
|
profile: 'Profile',
|
||||||
|
sessions: 'Tastings',
|
||||||
|
buddies: 'Buddies',
|
||||||
|
stats: 'Stats',
|
||||||
|
wishlist: 'Wishlist',
|
||||||
},
|
},
|
||||||
hub: {
|
hub: {
|
||||||
title: 'Activity Hub',
|
title: 'Activity Hub',
|
||||||
|
|||||||
@@ -197,6 +197,10 @@ export type TranslationKeys = {
|
|||||||
activity: string;
|
activity: string;
|
||||||
search: string;
|
search: string;
|
||||||
profile: string;
|
profile: string;
|
||||||
|
sessions: string;
|
||||||
|
buddies: string;
|
||||||
|
stats: string;
|
||||||
|
wishlist: string;
|
||||||
};
|
};
|
||||||
hub: {
|
hub: {
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
33
src/instrumentation.ts
Normal file
33
src/instrumentation.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
|
export async function register() {
|
||||||
|
const dsn = process.env.GLITCHTIP_DSN || process.env.NEXT_PUBLIC_GLITCHTIP_DSN;
|
||||||
|
|
||||||
|
if (!dsn) {
|
||||||
|
console.log("[Sentry] Instrumentation disabled - no DSN configured");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||||
|
// Server-side initialization
|
||||||
|
Sentry.init({
|
||||||
|
dsn,
|
||||||
|
environment: process.env.NODE_ENV,
|
||||||
|
sampleRate: 1.0,
|
||||||
|
tracesSampleRate: 0.1,
|
||||||
|
debug: process.env.NODE_ENV === "development",
|
||||||
|
});
|
||||||
|
console.log("[Sentry] Server initialized via instrumentation");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NEXT_RUNTIME === "edge") {
|
||||||
|
// Edge runtime initialization
|
||||||
|
Sentry.init({
|
||||||
|
dsn,
|
||||||
|
environment: process.env.NODE_ENV,
|
||||||
|
sampleRate: 1.0,
|
||||||
|
tracesSampleRate: 0.05,
|
||||||
|
});
|
||||||
|
console.log("[Sentry] Edge initialized via instrumentation");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,15 +4,19 @@ import type { SupabaseClient } from '@supabase/supabase-js';
|
|||||||
let supabaseClient: SupabaseClient | null = null;
|
let supabaseClient: SupabaseClient | null = null;
|
||||||
|
|
||||||
export function createClient() {
|
export function createClient() {
|
||||||
if (supabaseClient) return supabaseClient;
|
if (typeof window === 'undefined') {
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
return createBrowserClient(supabaseUrl, supabaseAnonKey);
|
||||||
|
|
||||||
if (!supabaseUrl || !supabaseAnonKey) {
|
|
||||||
throw new Error('Supabase URL and Anon Key must be defined');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
supabaseClient = createBrowserClient(supabaseUrl, supabaseAnonKey);
|
// Singleton for client-side to prevent multiple instances
|
||||||
return supabaseClient;
|
// Use window object to persist across module reloads in dev
|
||||||
|
if (!(window as any).supabase) {
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
|
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||||
|
(window as any).supabase = createBrowserClient(supabaseUrl, supabaseAnonKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (window as any).supabase as SupabaseClient;
|
||||||
}
|
}
|
||||||
|
|||||||
90
src/middleware.ts
Normal file
90
src/middleware.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { createServerClient } from "@supabase/ssr";
|
||||||
|
|
||||||
|
export async function middleware(request: NextRequest) {
|
||||||
|
let response = NextResponse.next({
|
||||||
|
request: {
|
||||||
|
headers: request.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const supabase = createServerClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return request.cookies.getAll();
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet: any[]) {
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
request.cookies.set(name, value)
|
||||||
|
);
|
||||||
|
response = NextResponse.next({
|
||||||
|
request: {
|
||||||
|
headers: request.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
response.cookies.set(name, value, options)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Refresh session if expired - required for Server Components
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
const path = request.nextUrl.pathname;
|
||||||
|
|
||||||
|
// 1. Define Public Routes (Whitelist)
|
||||||
|
const isPublic =
|
||||||
|
path === "/" ||
|
||||||
|
path.startsWith("/auth") || // Auth callbacks
|
||||||
|
path.startsWith("/api") || // API routes (often handle their own auth or are public)
|
||||||
|
path === "/manifest.webmanifest" ||
|
||||||
|
path === "/sw.js" ||
|
||||||
|
path === "/offline" ||
|
||||||
|
path.startsWith("/icons") ||
|
||||||
|
path.startsWith("/_next"); // Static assets
|
||||||
|
|
||||||
|
// 2. Specialized Logic for /splits
|
||||||
|
const isSplitsPublic =
|
||||||
|
path.startsWith("/splits/") &&
|
||||||
|
!path.startsWith("/splits/create") &&
|
||||||
|
!path.startsWith("/splits/manage");
|
||||||
|
|
||||||
|
if (isPublic || isSplitsPublic) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Protected Routes
|
||||||
|
// If no user, redirect to Home (which acts as Login)
|
||||||
|
if (!user) {
|
||||||
|
const redirectUrl = request.nextUrl.clone();
|
||||||
|
redirectUrl.pathname = "/";
|
||||||
|
// Add redirect param so Client can show Login Modal if needed?
|
||||||
|
// Or just let them land on Home.
|
||||||
|
// Ideally we'd persist the return URL:
|
||||||
|
redirectUrl.searchParams.set("redirect_to", path);
|
||||||
|
return NextResponse.redirect(redirectUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
/*
|
||||||
|
* Match all request paths except for the ones starting with:
|
||||||
|
* - _next/static (static files)
|
||||||
|
* - _next/image (image optimization files)
|
||||||
|
* - favicon.ico (favicon file)
|
||||||
|
* Feel free to modify this pattern to include more paths.
|
||||||
|
*/
|
||||||
|
"/((?!_next/static|_next/image|favicon.ico).*)",
|
||||||
|
],
|
||||||
|
};
|
||||||
53
src/proxy.ts
53
src/proxy.ts
@@ -1,53 +0,0 @@
|
|||||||
import { createServerClient, type CookieOptions } from '@supabase/ssr';
|
|
||||||
import { NextResponse, type NextRequest } from 'next/server';
|
|
||||||
|
|
||||||
export async function proxy(request: NextRequest) {
|
|
||||||
let response = NextResponse.next({
|
|
||||||
request: {
|
|
||||||
headers: request.headers,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const supabase = createServerClient(
|
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
||||||
{
|
|
||||||
cookies: {
|
|
||||||
getAll() {
|
|
||||||
return request.cookies.getAll();
|
|
||||||
},
|
|
||||||
setAll(cookiesToSet: { name: string; value: string; options: CookieOptions }[]) {
|
|
||||||
cookiesToSet.forEach(({ name, value, options }) =>
|
|
||||||
request.cookies.set(name, value)
|
|
||||||
);
|
|
||||||
response = NextResponse.next({
|
|
||||||
request: {
|
|
||||||
headers: request.headers,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
cookiesToSet.forEach(({ name, value, options }) =>
|
|
||||||
response.cookies.set(name, value, options)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
|
||||||
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const isStatic = url.pathname.startsWith('/_next') || url.pathname.includes('/icon-') || url.pathname === '/favicon.ico';
|
|
||||||
|
|
||||||
if (!isStatic) {
|
|
||||||
const status = user ? `User:${user.id.slice(0, 8)}` : 'No Session';
|
|
||||||
console.log(`[Proxy] ${request.method} ${url.pathname} | ${status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
matcher: [
|
|
||||||
'/((?!_next/static|_next/image|favicon.ico).*)',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
191
src/services/analyze-bottle-mistral.ts
Normal file
191
src/services/analyze-bottle-mistral.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { Mistral } from '@mistralai/mistralai';
|
||||||
|
import { getSystemPrompt } from '@/lib/ai-prompts';
|
||||||
|
import { BottleMetadataSchema, AnalysisResponse, BottleMetadata } from '@/types/whisky';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
import { trackApiUsage } from './track-api-usage';
|
||||||
|
import { checkCreditBalance, deductCredits } from './credit-service';
|
||||||
|
|
||||||
|
// WICHTIG: Wir akzeptieren jetzt FormData statt Strings
|
||||||
|
export async function analyzeBottleMistral(input: any): Promise<AnalysisResponse & { search_string?: string }> {
|
||||||
|
if (!process.env.MISTRAL_API_KEY) {
|
||||||
|
return { success: false, error: 'MISTRAL_API_KEY is not configured.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
let supabase;
|
||||||
|
try {
|
||||||
|
// Helper to get value from either FormData or POJO
|
||||||
|
const getValue = (obj: any, key: string): any => {
|
||||||
|
if (obj && typeof obj.get === 'function') return obj.get(key);
|
||||||
|
if (obj && typeof obj[key] !== 'undefined') return obj[key];
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Daten extrahieren
|
||||||
|
const file = getValue(input, 'file') as File;
|
||||||
|
const tagsString = getValue(input, 'tags') as string;
|
||||||
|
const locale = getValue(input, 'locale') || 'de';
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return { success: false, error: 'Kein Bild empfangen.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = tagsString ? (typeof tagsString === 'string' ? JSON.parse(tagsString) : tagsString) : [];
|
||||||
|
|
||||||
|
// 2. Auth & Credits
|
||||||
|
supabase = await createClient();
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, error: 'Nicht autorisiert oder Session abgelaufen.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = user.id;
|
||||||
|
const creditCheck = await checkCreditBalance(userId, 'gemini_ai');
|
||||||
|
if (!creditCheck.allowed) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Nicht genügend Credits. Benötigt: ${creditCheck.cost}, Verfügbar: ${creditCheck.balance}.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Datei in Buffer umwandeln
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
|
||||||
|
// 4. Hash für Cache erstellen
|
||||||
|
const imageHash = createHash('sha256').update(buffer).digest('hex');
|
||||||
|
|
||||||
|
// Cache Check
|
||||||
|
const { data: cachedResult } = await supabase
|
||||||
|
.from('vision_cache')
|
||||||
|
.select('result')
|
||||||
|
.eq('hash', imageHash)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (cachedResult) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: cachedResult.result as any,
|
||||||
|
perf: {
|
||||||
|
apiDuration: 0,
|
||||||
|
parseDuration: 0,
|
||||||
|
uploadSize: buffer.length
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Für Mistral vorbereiten
|
||||||
|
const client = new Mistral({ apiKey: process.env.MISTRAL_API_KEY });
|
||||||
|
const base64Data = buffer.toString('base64');
|
||||||
|
const mimeType = file.type || 'image/webp';
|
||||||
|
const dataUrl = `data:${mimeType};base64,${base64Data}`;
|
||||||
|
const uploadSize = buffer.length;
|
||||||
|
|
||||||
|
const prompt = getSystemPrompt(tags.length > 0 ? tags.join(', ') : 'Keine Tags verfügbar', locale);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startApi = performance.now();
|
||||||
|
const chatResponse = await client.chat.complete({
|
||||||
|
model: 'mistral-large-latest',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: prompt },
|
||||||
|
{ type: 'image_url', imageUrl: dataUrl }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
responseFormat: { type: 'json_object' },
|
||||||
|
temperature: 0.1
|
||||||
|
});
|
||||||
|
const endApi = performance.now();
|
||||||
|
|
||||||
|
const startParse = performance.now();
|
||||||
|
const rawContent = chatResponse.choices?.[0].message.content;
|
||||||
|
if (!rawContent) throw new Error("Keine Antwort von Mistral");
|
||||||
|
|
||||||
|
let jsonData;
|
||||||
|
try {
|
||||||
|
jsonData = JSON.parse(rawContent as string);
|
||||||
|
} catch (e) {
|
||||||
|
const cleanedText = (rawContent as string).replace(/```json/g, '').replace(/```/g, '');
|
||||||
|
jsonData = JSON.parse(cleanedText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(jsonData)) jsonData = jsonData[0];
|
||||||
|
console.log('[Mistral AI] JSON Response:', jsonData);
|
||||||
|
|
||||||
|
const searchString = jsonData.search_string;
|
||||||
|
delete jsonData.search_string;
|
||||||
|
|
||||||
|
if (typeof jsonData.abv === 'string') {
|
||||||
|
// Fix: Global replace to remove all % signs
|
||||||
|
jsonData.abv = parseFloat(jsonData.abv.replace(/%/g, '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonData.age) jsonData.age = parseInt(jsonData.age);
|
||||||
|
if (jsonData.vintage) jsonData.vintage = parseInt(jsonData.vintage);
|
||||||
|
|
||||||
|
const validatedData = BottleMetadataSchema.parse(jsonData);
|
||||||
|
const endParse = performance.now();
|
||||||
|
|
||||||
|
await trackApiUsage({
|
||||||
|
userId: userId,
|
||||||
|
apiType: 'gemini_ai',
|
||||||
|
endpoint: 'mistral/mistral-large',
|
||||||
|
success: true,
|
||||||
|
provider: 'mistral',
|
||||||
|
model: 'mistral-large-latest',
|
||||||
|
responseText: rawContent as string
|
||||||
|
});
|
||||||
|
|
||||||
|
await deductCredits(userId, 'gemini_ai', 'Mistral AI analysis');
|
||||||
|
|
||||||
|
await supabase
|
||||||
|
.from('vision_cache')
|
||||||
|
.insert({ hash: imageHash, result: validatedData });
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: validatedData,
|
||||||
|
search_string: searchString,
|
||||||
|
perf: {
|
||||||
|
apiDuration: endApi - startApi,
|
||||||
|
parseDuration: endParse - startParse,
|
||||||
|
uploadSize: uploadSize
|
||||||
|
},
|
||||||
|
raw: jsonData
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (aiError: any) {
|
||||||
|
console.warn('[MistralAnalysis] AI Analysis failed, providing fallback path:', aiError.message);
|
||||||
|
|
||||||
|
await trackApiUsage({
|
||||||
|
userId: userId,
|
||||||
|
apiType: 'gemini_ai',
|
||||||
|
endpoint: 'mistral/mistral-large',
|
||||||
|
success: false,
|
||||||
|
errorMessage: aiError.message,
|
||||||
|
provider: 'mistral',
|
||||||
|
model: 'mistral-large-latest'
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
isAiError: true,
|
||||||
|
error: aiError.message,
|
||||||
|
imageHash: imageHash
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Mistral Analysis Global Error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Mistral AI analysis failed.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
123
src/services/banner-actions.ts
Normal file
123
src/services/banner-actions.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
|
export interface Banner {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
image_url: string;
|
||||||
|
link_target: string | null;
|
||||||
|
cta_text: string;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBanners(): Promise<{ banners: Banner[]; error: string | null }> {
|
||||||
|
const supabase = await createClient();
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('app_banners')
|
||||||
|
.select('*')
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('[getBanners] Error:', error);
|
||||||
|
return { banners: [], error: error.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { banners: data || [], error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createBanner(formData: FormData): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const supabase = await createClient();
|
||||||
|
|
||||||
|
const title = formData.get('title') as string;
|
||||||
|
const image_url = formData.get('image_url') as string;
|
||||||
|
const link_target = formData.get('link_target') as string || null;
|
||||||
|
const cta_text = formData.get('cta_text') as string || 'Open';
|
||||||
|
|
||||||
|
if (!title || !image_url) {
|
||||||
|
return { success: false, error: 'Title and image URL are required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('app_banners')
|
||||||
|
.insert({ title, image_url, link_target, cta_text, is_active: false });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('[createBanner] Error:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath('/admin/banners');
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateBanner(id: string, formData: FormData): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const supabase = await createClient();
|
||||||
|
|
||||||
|
const title = formData.get('title') as string;
|
||||||
|
const image_url = formData.get('image_url') as string;
|
||||||
|
const link_target = formData.get('link_target') as string || null;
|
||||||
|
const cta_text = formData.get('cta_text') as string || 'Open';
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('app_banners')
|
||||||
|
.update({ title, image_url, link_target, cta_text })
|
||||||
|
.eq('id', id);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('[updateBanner] Error:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath('/admin/banners');
|
||||||
|
revalidatePath('/');
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleBannerActive(id: string, isActive: boolean): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const supabase = await createClient();
|
||||||
|
|
||||||
|
// If activating, first deactivate all other banners (only one active at a time)
|
||||||
|
if (isActive) {
|
||||||
|
await supabase
|
||||||
|
.from('app_banners')
|
||||||
|
.update({ is_active: false })
|
||||||
|
.neq('id', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('app_banners')
|
||||||
|
.update({ is_active: isActive })
|
||||||
|
.eq('id', id);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('[toggleBannerActive] Error:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath('/admin/banners');
|
||||||
|
revalidatePath('/');
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBanner(id: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const supabase = await createClient();
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('app_banners')
|
||||||
|
.delete()
|
||||||
|
.eq('id', id);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('[deleteBanner] Error:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath('/admin/banners');
|
||||||
|
revalidatePath('/');
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import type { Config } from "tailwindcss";
|
|
||||||
|
|
||||||
const config: Config = {
|
|
||||||
content: [
|
|
||||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
|
||||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
|
||||||
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
|
||||||
],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
backgroundImage: {
|
|
||||||
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
|
|
||||||
"gradient-conic":
|
|
||||||
"conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
};
|
|
||||||
export default config;
|
|
||||||
Reference in New Issue
Block a user