casino-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/README.md +213 -0
  2. package/dist/assets/assets/card-back.svg +43 -0
  3. package/dist/assets/assets/card-shoe-overlay.svg +14 -0
  4. package/dist/assets/card-back.svg +43 -0
  5. package/dist/assets/card-shoe-overlay.svg +14 -0
  6. package/dist/casino-ui.css +2 -0
  7. package/dist/components/bet-panel.d.ts +41 -0
  8. package/dist/components/bet-panel.d.ts.map +1 -0
  9. package/dist/components/bet-panel.js +37 -0
  10. package/dist/components/bet-panel.js.map +1 -0
  11. package/dist/components/button.d.ts +15 -0
  12. package/dist/components/button.d.ts.map +1 -0
  13. package/dist/components/button.js +27 -0
  14. package/dist/components/button.js.map +1 -0
  15. package/dist/components/card-score-badge.d.ts +9 -0
  16. package/dist/components/card-score-badge.d.ts.map +1 -0
  17. package/dist/components/card-score-badge.js +20 -0
  18. package/dist/components/card-score-badge.js.map +1 -0
  19. package/dist/components/card-shoe.d.ts +6 -0
  20. package/dist/components/card-shoe.d.ts.map +1 -0
  21. package/dist/components/card-shoe.js +14 -0
  22. package/dist/components/card-shoe.js.map +1 -0
  23. package/dist/components/card-table.d.ts +7 -0
  24. package/dist/components/card-table.d.ts.map +1 -0
  25. package/dist/components/card-table.js +6 -0
  26. package/dist/components/card-table.js.map +1 -0
  27. package/dist/components/chip-rack.d.ts +6 -0
  28. package/dist/components/chip-rack.d.ts.map +1 -0
  29. package/dist/components/chip-rack.js +13 -0
  30. package/dist/components/chip-rack.js.map +1 -0
  31. package/dist/components/chip.d.ts +49 -0
  32. package/dist/components/chip.d.ts.map +1 -0
  33. package/dist/components/chip.js +81 -0
  34. package/dist/components/chip.js.map +1 -0
  35. package/dist/components/game-footer.d.ts +29 -0
  36. package/dist/components/game-footer.d.ts.map +1 -0
  37. package/dist/components/game-footer.js +72 -0
  38. package/dist/components/game-footer.js.map +1 -0
  39. package/dist/components/game-info-dialog.d.ts +23 -0
  40. package/dist/components/game-info-dialog.d.ts.map +1 -0
  41. package/dist/components/game-info-dialog.js +26 -0
  42. package/dist/components/game-info-dialog.js.map +1 -0
  43. package/dist/components/icon.d.ts +10 -0
  44. package/dist/components/icon.d.ts.map +1 -0
  45. package/dist/components/icon.js +59 -0
  46. package/dist/components/icon.js.map +1 -0
  47. package/dist/components/poker-card.d.ts +15 -0
  48. package/dist/components/poker-card.d.ts.map +1 -0
  49. package/dist/components/poker-card.js +77 -0
  50. package/dist/components/poker-card.js.map +1 -0
  51. package/dist/components/result-badge.d.ts +14 -0
  52. package/dist/components/result-badge.d.ts.map +1 -0
  53. package/dist/components/result-badge.js +71 -0
  54. package/dist/components/result-badge.js.map +1 -0
  55. package/dist/components/switch-table-dialog.d.ts +10 -0
  56. package/dist/components/switch-table-dialog.d.ts.map +1 -0
  57. package/dist/components/switch-table-dialog.js +51 -0
  58. package/dist/components/switch-table-dialog.js.map +1 -0
  59. package/dist/components/toast-container.d.ts +8 -0
  60. package/dist/components/toast-container.d.ts.map +1 -0
  61. package/dist/components/toast-container.js +8 -0
  62. package/dist/components/toast-container.js.map +1 -0
  63. package/dist/components/toast-helpers.d.ts +22 -0
  64. package/dist/components/toast-helpers.d.ts.map +1 -0
  65. package/dist/components/toast-helpers.js +47 -0
  66. package/dist/components/toast-helpers.js.map +1 -0
  67. package/dist/components/toast.d.ts +26 -0
  68. package/dist/components/toast.d.ts.map +1 -0
  69. package/dist/components/toast.js +49 -0
  70. package/dist/components/toast.js.map +1 -0
  71. package/dist/components/ui/dialog.d.ts +14 -0
  72. package/dist/components/ui/dialog.d.ts.map +1 -0
  73. package/dist/components/ui/dialog.js +35 -0
  74. package/dist/components/ui/dialog.js.map +1 -0
  75. package/dist/components/ui/drawer.d.ts +14 -0
  76. package/dist/components/ui/drawer.d.ts.map +1 -0
  77. package/dist/components/ui/drawer.js +35 -0
  78. package/dist/components/ui/drawer.js.map +1 -0
  79. package/dist/components/ui/dropdown-menu.d.ts +27 -0
  80. package/dist/components/ui/dropdown-menu.d.ts.map +1 -0
  81. package/dist/components/ui/dropdown-menu.js +39 -0
  82. package/dist/components/ui/dropdown-menu.js.map +1 -0
  83. package/dist/index.d.ts +23 -0
  84. package/dist/index.d.ts.map +1 -0
  85. package/dist/index.js +25 -0
  86. package/dist/index.js.map +1 -0
  87. package/dist/lib/utils.d.ts +3 -0
  88. package/dist/lib/utils.d.ts.map +1 -0
  89. package/dist/lib/utils.js +6 -0
  90. package/dist/lib/utils.js.map +1 -0
  91. package/dist/stories/BetPanel.stories.d.ts +10 -0
  92. package/dist/stories/BetPanel.stories.d.ts.map +1 -0
  93. package/dist/stories/BetPanel.stories.js +34 -0
  94. package/dist/stories/BetPanel.stories.js.map +1 -0
  95. package/dist/stories/Button.stories.d.ts +13 -0
  96. package/dist/stories/Button.stories.d.ts.map +1 -0
  97. package/dist/stories/Button.stories.js +39 -0
  98. package/dist/stories/Button.stories.js.map +1 -0
  99. package/dist/stories/CardScoreBadge.stories.d.ts +8 -0
  100. package/dist/stories/CardScoreBadge.stories.d.ts.map +1 -0
  101. package/dist/stories/CardScoreBadge.stories.js +21 -0
  102. package/dist/stories/CardScoreBadge.stories.js.map +1 -0
  103. package/dist/stories/CardShoe.stories.d.ts +7 -0
  104. package/dist/stories/CardShoe.stories.d.ts.map +1 -0
  105. package/dist/stories/CardShoe.stories.js +8 -0
  106. package/dist/stories/CardShoe.stories.js.map +1 -0
  107. package/dist/stories/CardTable.stories.d.ts +7 -0
  108. package/dist/stories/CardTable.stories.d.ts.map +1 -0
  109. package/dist/stories/CardTable.stories.js +15 -0
  110. package/dist/stories/CardTable.stories.js.map +1 -0
  111. package/dist/stories/Chip.stories.d.ts +11 -0
  112. package/dist/stories/Chip.stories.d.ts.map +1 -0
  113. package/dist/stories/Chip.stories.js +32 -0
  114. package/dist/stories/Chip.stories.js.map +1 -0
  115. package/dist/stories/ChipRack.stories.d.ts +7 -0
  116. package/dist/stories/ChipRack.stories.d.ts.map +1 -0
  117. package/dist/stories/ChipRack.stories.js +8 -0
  118. package/dist/stories/ChipRack.stories.js.map +1 -0
  119. package/dist/stories/Dialog.stories.d.ts +6 -0
  120. package/dist/stories/Dialog.stories.d.ts.map +1 -0
  121. package/dist/stories/Dialog.stories.js +15 -0
  122. package/dist/stories/Dialog.stories.js.map +1 -0
  123. package/dist/stories/GameFooter.stories.d.ts +7 -0
  124. package/dist/stories/GameFooter.stories.d.ts.map +1 -0
  125. package/dist/stories/GameFooter.stories.js +37 -0
  126. package/dist/stories/GameFooter.stories.js.map +1 -0
  127. package/dist/stories/Icon.stories.d.ts +9 -0
  128. package/dist/stories/Icon.stories.d.ts.map +1 -0
  129. package/dist/stories/Icon.stories.js +27 -0
  130. package/dist/stories/Icon.stories.js.map +1 -0
  131. package/dist/stories/PokerCard.stories.d.ts +14 -0
  132. package/dist/stories/PokerCard.stories.d.ts.map +1 -0
  133. package/dist/stories/PokerCard.stories.js +40 -0
  134. package/dist/stories/PokerCard.stories.js.map +1 -0
  135. package/dist/stories/ResultBadge.stories.d.ts +11 -0
  136. package/dist/stories/ResultBadge.stories.d.ts.map +1 -0
  137. package/dist/stories/ResultBadge.stories.js +32 -0
  138. package/dist/stories/ResultBadge.stories.js.map +1 -0
  139. package/dist/stories/Toast.stories.d.ts +10 -0
  140. package/dist/stories/Toast.stories.d.ts.map +1 -0
  141. package/dist/stories/Toast.stories.js +34 -0
  142. package/dist/stories/Toast.stories.js.map +1 -0
  143. package/dist/styles/casino-ui-base.css +310 -0
  144. package/dist/styles/styles/casino-ui-base.css +310 -0
  145. package/dist/styles/styles/toast.css +52 -0
  146. package/dist/styles/toast.css +52 -0
  147. package/dist/toast.css +52 -0
  148. package/dist/types.d.ts +11 -0
  149. package/dist/types.d.ts.map +1 -0
  150. package/dist/types.js +2 -0
  151. package/dist/types.js.map +1 -0
  152. package/dist/utils/format-amount.d.ts +2 -0
  153. package/dist/utils/format-amount.d.ts.map +1 -0
  154. package/dist/utils/format-amount.js +31 -0
  155. package/dist/utils/format-amount.js.map +1 -0
  156. package/package.json +58 -0
  157. package/src/assets/card-back.svg +43 -0
  158. package/src/assets/card-shoe-overlay.svg +14 -0
  159. package/src/components/bet-panel.tsx +275 -0
  160. package/src/components/button.tsx +80 -0
  161. package/src/components/card-score-badge.tsx +61 -0
  162. package/src/components/card-shoe.tsx +52 -0
  163. package/src/components/card-table.tsx +182 -0
  164. package/src/components/chip-rack.tsx +55 -0
  165. package/src/components/chip.tsx +203 -0
  166. package/src/components/game-footer.tsx +356 -0
  167. package/src/components/game-info-dialog.tsx +245 -0
  168. package/src/components/icon.tsx +94 -0
  169. package/src/components/poker-card.tsx +192 -0
  170. package/src/components/result-badge.tsx +157 -0
  171. package/src/components/switch-table-dialog.tsx +211 -0
  172. package/src/components/toast-container.tsx +25 -0
  173. package/src/components/toast-helpers.ts +79 -0
  174. package/src/components/toast.tsx +282 -0
  175. package/src/components/ui/dialog.tsx +134 -0
  176. package/src/components/ui/drawer.tsx +132 -0
  177. package/src/components/ui/dropdown-menu.tsx +210 -0
  178. package/src/env.d.ts +6 -0
  179. package/src/index.ts +88 -0
  180. package/src/lib/utils.ts +6 -0
  181. package/src/stories/BetPanel.stories.tsx +113 -0
  182. package/src/stories/Button.stories.tsx +55 -0
  183. package/src/stories/CardScoreBadge.stories.tsx +34 -0
  184. package/src/stories/CardShoe.stories.tsx +12 -0
  185. package/src/stories/Chip.stories.tsx +51 -0
  186. package/src/stories/ChipRack.stories.tsx +12 -0
  187. package/src/stories/Dialog.stories.tsx +45 -0
  188. package/src/stories/GameFooter.stories.tsx +45 -0
  189. package/src/stories/Icon.stories.tsx +49 -0
  190. package/src/stories/PokerCard.stories.tsx +72 -0
  191. package/src/stories/ResultBadge.stories.tsx +51 -0
  192. package/src/stories/Toast.stories.tsx +71 -0
  193. package/src/styles/casino-ui-base.css +310 -0
  194. package/src/styles/toast.css +52 -0
  195. package/src/types.ts +11 -0
  196. package/src/utils/format-amount.ts +35 -0
@@ -0,0 +1,55 @@
1
+ import { forwardRef } from "react";
2
+ import { cn } from "../lib/utils";
3
+ import Chip, { CHIP_DENOMINATIONS } from "./chip";
4
+
5
+ const ROW_MARGINS = ["mt-[15px]", "mt-[10px]", "mt-[5px]", "mt-0"];
6
+ const NUM_ROWS = 4;
7
+
8
+ interface ChipRackProps {
9
+ className?: string;
10
+ }
11
+
12
+ export const ChipRack = forwardRef<HTMLDivElement, ChipRackProps>(
13
+ function ChipRack({ className }, ref) {
14
+ return (
15
+ <div
16
+ ref={ref}
17
+ className={cn(
18
+ "relative bg-card-shoe-bg border-3 w-76 h-20 border-card-shoe-border rounded-lg shadow-[0px_2.392px_0px_0px_#333] pt-4 pb-2.5 px-1.5",
19
+ className,
20
+ )}
21
+ >
22
+ {/* Chip grid — all 4 rows overlap in same grid cell, margin increases for depth */}
23
+ <div className="inline-grid">
24
+ {Array.from({ length: NUM_ROWS }).map((_, rowIdx) => (
25
+ <div
26
+ key={rowIdx}
27
+ data-row={rowIdx}
28
+ className={cn("flex gap-2 [grid-area:1/1]", ROW_MARGINS[rowIdx])}
29
+ >
30
+ {CHIP_DENOMINATIONS.map((denom) => (
31
+ <div key={denom} data-denomination={denom}>
32
+ <Chip
33
+ denomination={denom}
34
+ wrapperClass={`w-8.5 h-8.5 p-0`}
35
+ chipClass={`w-full h-full`}
36
+ showText={false}
37
+ />
38
+ </div>
39
+ ))}
40
+ </div>
41
+ ))}
42
+ </div>
43
+
44
+ {/* Top bar overlay — semi-transparent lip of the tray */}
45
+ <div
46
+ className="absolute -top-[3px] left-[-3px] w-76 h-11 bg-card-shoe-bg/60 border-3 border-card-shoe-border rounded-lg z-10 backdrop-blur-[3px]"
47
+ style={{
48
+ backdropFilter: "blur(3px)",
49
+ WebkitBackdropFilter: "blur(3px)",
50
+ }}
51
+ />
52
+ </div>
53
+ );
54
+ },
55
+ );
@@ -0,0 +1,203 @@
1
+ import { cn } from "../lib/utils";
2
+ import { formatAmountDirect } from "../utils/format-amount";
3
+
4
+ type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement>;
5
+ type DivProps = React.HTMLAttributes<HTMLDivElement>;
6
+
7
+ interface IChipProps {
8
+ denomination?: number;
9
+ amount?: number;
10
+ wrapperClass?: ButtonProps["className"] | DivProps["className"];
11
+ chipClass?: DivProps["className"];
12
+ textClass?: React.HTMLAttributes<HTMLSpanElement>["className"];
13
+ onClick?: () => void;
14
+ showText?: boolean;
15
+ isButton?: boolean;
16
+ selected?: boolean;
17
+ }
18
+
19
+ const hoverEffect = `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 48 49" fill="none">
20
+ <g filter="url(#filter0_d_6722_40653)">
21
+ <ellipse cx="24" cy="23" rx="22" ry="23" fill="#C7FE51" fill-opacity="0.4" shape-rendering="crispEdges"/>
22
+ </g>
23
+ <defs>
24
+ <filter id="filter0_d_6722_40653" x="0" y="0" width="48" height="50" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
25
+ <feFlood flood-opacity="0" result="BackgroundImageFix"/>
26
+ <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
27
+ <feOffset dy="2"/>
28
+ <feGaussianBlur stdDeviation="1"/>
29
+ <feComposite in2="hardAlpha" operator="out"/>
30
+ <feColorMatrix type="matrix" values="0 0 0 0 0.780392 0 0 0 0 0.996078 0 0 0 0 0.317647 0 0 0 0.6 0"/>
31
+ <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_6722_40653"/>
32
+ <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_6722_40653" result="shape"/>
33
+ </filter>
34
+ </defs>
35
+ </svg>`;
36
+
37
+ const selectedEffect = `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 46 48" fill="none">
38
+ <ellipse cx="23" cy="24" rx="23" ry="24" fill="#C7FE51"/>
39
+ </svg>`;
40
+
41
+ export const CHIP_PRESETS = {
42
+ 1: { color: "#999999", shadowColor: "#474747" },
43
+ 5: { color: "#DB375E", shadowColor: "#63171D" },
44
+ 10: { color: "#037A4C", shadowColor: "#06503F" },
45
+ 25: { color: "#F57FE0", shadowColor: "#951F9D" },
46
+ 50: { color: "#623CEA", shadowColor: "#2D268B" },
47
+ 100: { color: "#E3B800", shadowColor: "#816B0C" },
48
+ 500: { color: "#9727B0", shadowColor: "#5B187D" },
49
+ } as const;
50
+
51
+ export type ChipDenomination = keyof typeof CHIP_PRESETS;
52
+
53
+ export const CHIP_DENOMINATIONS = Object.keys(CHIP_PRESETS).map(
54
+ Number,
55
+ ) as ChipDenomination[];
56
+
57
+ export function getChipDenominationForAmount(amount: number): ChipDenomination {
58
+ const absAmount = Math.abs(amount);
59
+ if (absAmount >= 500) return 500;
60
+ if (absAmount >= 100) return 100;
61
+ if (absAmount >= 50) return 50;
62
+ if (absAmount >= 25) return 25;
63
+ if (absAmount >= 10) return 10;
64
+ if (absAmount >= 5) return 5;
65
+ return 1;
66
+ }
67
+
68
+ function normalizeDenomination(denomination?: number): ChipDenomination {
69
+ if (
70
+ typeof denomination === "number" &&
71
+ CHIP_DENOMINATIONS.includes(denomination as ChipDenomination)
72
+ ) {
73
+ return denomination as ChipDenomination;
74
+ }
75
+ return 1;
76
+ }
77
+
78
+ const ChipBody = ({
79
+ denomination,
80
+ amount,
81
+ chipClass = "w-11 h-11",
82
+ textClass,
83
+ showText = true,
84
+ isButton = false,
85
+ selected = false,
86
+ }: Omit<IChipProps, "wrapperClass" | "onClick">) => {
87
+ const resolvedDenomination =
88
+ typeof amount === "number"
89
+ ? getChipDenominationForAmount(amount)
90
+ : normalizeDenomination(denomination);
91
+ const chipText =
92
+ typeof amount === "number"
93
+ ? formatAmountDirect(Math.round(amount * 100) / 100)
94
+ : resolvedDenomination;
95
+ const resolvedTextClass =
96
+ typeof amount === "number"
97
+ ? "leading-none tracking-tighter text-[10px]"
98
+ : "leading-5.5 text-[10px] md:leading-7.5 md:text-[12.865px]";
99
+
100
+ return (
101
+ <>
102
+ <div
103
+ className={cn(
104
+ "relative rounded-[50%] aspect-square overflow-clip chip-body p-[4.566px] z-2",
105
+ chipClass,
106
+ )}
107
+ style={{
108
+ backgroundColor: CHIP_PRESETS[resolvedDenomination].color,
109
+ boxShadow: `0px 3px 0px 0px ${CHIP_PRESETS[resolvedDenomination].shadowColor}`,
110
+ }}
111
+ >
112
+ <div
113
+ className="relative z-1 w-full h-full rounded-[50%] flex items-center justify-center select-none"
114
+ style={{
115
+ backgroundColor: CHIP_PRESETS[resolvedDenomination].color,
116
+ }}
117
+ >
118
+ {showText && (
119
+ <span
120
+ data-slot="chip-text"
121
+ className={cn(
122
+ "font-semibold md:font-bold text-center text-white",
123
+ resolvedTextClass,
124
+ textClass,
125
+ )}
126
+ >
127
+ {chipText}
128
+ </span>
129
+ )}
130
+ </div>
131
+ </div>
132
+
133
+ {isButton && (
134
+ <>
135
+ {/* Hover effect */}
136
+ <div
137
+ className={cn(
138
+ "absolute inset-0 opacity-0 ease-in-out duration-300 w-9 h-9.5 md:w-11.5 md:h-12",
139
+ !selected && "group-hover:opacity-100",
140
+ )}
141
+ dangerouslySetInnerHTML={{ __html: hoverEffect }}
142
+ />
143
+ {/* Active/Selected effect */}
144
+ <div
145
+ className={cn(
146
+ "absolute top-0 left-1/2 -translate-x-1/2 ease-in-out duration-300 w-9 h-9.5 md:w-11.5 md:h-12",
147
+ selected ? "opacity-100" : "group-active:opacity-100 opacity-0",
148
+ )}
149
+ dangerouslySetInnerHTML={{ __html: selectedEffect }}
150
+ />
151
+ </>
152
+ )}
153
+ </>
154
+ );
155
+ };
156
+
157
+ const Chip = ({
158
+ denomination,
159
+ amount,
160
+ wrapperClass,
161
+ chipClass = "w-11 h-11",
162
+ textClass,
163
+ onClick,
164
+ showText = true,
165
+ isButton = false,
166
+ selected = false,
167
+ }: IChipProps) => {
168
+ if (!isButton) {
169
+ return (
170
+ <div className={cn("relative px-0.5 pb-1 group", wrapperClass)}>
171
+ <ChipBody
172
+ denomination={denomination}
173
+ amount={amount}
174
+ chipClass={chipClass}
175
+ textClass={textClass}
176
+ showText={showText}
177
+ isButton={isButton}
178
+ selected={selected}
179
+ />
180
+ </div>
181
+ );
182
+ }
183
+
184
+ return (
185
+ <button
186
+ type="button"
187
+ onClick={onClick}
188
+ className={cn("relative px-0.5 pb-1 group cursor-pointer", wrapperClass)}
189
+ >
190
+ <ChipBody
191
+ denomination={denomination}
192
+ amount={amount}
193
+ chipClass={chipClass}
194
+ textClass={textClass}
195
+ showText={showText}
196
+ isButton={isButton}
197
+ selected={selected}
198
+ />
199
+ </button>
200
+ );
201
+ };
202
+
203
+ export default Chip;
@@ -0,0 +1,356 @@
1
+ import { useState, useMemo, useCallback, useRef, useEffect } from "react";
2
+ import {
3
+ DropdownMenu,
4
+ DropdownMenuContent,
5
+ DropdownMenuTrigger,
6
+ } from "./ui/dropdown-menu";
7
+ import { Icon } from "./icon";
8
+ import GameInfoDialog from "./game-info-dialog";
9
+ import SwitchTableDialog from "./switch-table-dialog";
10
+ import { Button } from "./button";
11
+ import { cn } from "../lib/utils";
12
+ import type { CoinInfo } from "../types";
13
+
14
+ interface GameFooterProps {
15
+ tableId: string;
16
+ shareBaseUrl: string | null;
17
+ badges: {
18
+ houseEdge: string;
19
+ minBet: string;
20
+ coinInfo?: CoinInfo;
21
+ };
22
+ tabsContents: {
23
+ table: React.ReactNode;
24
+ howToPlay: React.ReactNode;
25
+ balance: React.ReactNode;
26
+ };
27
+ volumeSlider: {
28
+ globalVolume: number;
29
+ setGlobalVolume: (volume: number) => void;
30
+ setPrevVolume: (volume: number) => void;
31
+ };
32
+ onToggleMute: () => void;
33
+ onJoin: (tableIdOrLink: string) => void;
34
+ isMobile?: boolean;
35
+ gameTitle?: string;
36
+ gameDescription?: string;
37
+ centerLogoUrl?: string;
38
+ }
39
+
40
+ interface VolumeSliderProps {
41
+ globalVolume: number;
42
+ setGlobalVolume: (volume: number) => void;
43
+ setPrevVolume: (volume: number) => void;
44
+ onToggleMute: () => void;
45
+ }
46
+
47
+ function VolumeSlider({
48
+ globalVolume,
49
+ setGlobalVolume,
50
+ setPrevVolume,
51
+ onToggleMute,
52
+ }: VolumeSliderProps) {
53
+ const sliderRef = useRef<HTMLInputElement>(null);
54
+
55
+ const handleChange = useCallback(
56
+ (e: React.ChangeEvent<HTMLInputElement>) => {
57
+ const val = parseFloat(e.target.value);
58
+ if (val > 0) setPrevVolume(val);
59
+ setGlobalVolume(val);
60
+ },
61
+ [setGlobalVolume, setPrevVolume],
62
+ );
63
+
64
+ useEffect(() => {
65
+ const el = sliderRef.current;
66
+ if (!el) return;
67
+ const pct = globalVolume * 100;
68
+ el.style.background = `linear-gradient(to right, var(--color-accent-success) ${pct}%, var(--color-surface-250) ${pct}%)`;
69
+ }, [globalVolume]);
70
+
71
+ return (
72
+ <div className="flex items-center gap-2 px-2 py-3">
73
+ <Icon
74
+ icon="sound"
75
+ className="size-4 text-text-secondary"
76
+ onClick={() => onToggleMute()}
77
+ />
78
+ <input
79
+ ref={sliderRef}
80
+ type="range"
81
+ min={0}
82
+ max={1}
83
+ step={0.01}
84
+ value={globalVolume}
85
+ onChange={handleChange}
86
+ className="volume-slider flex-1"
87
+ />
88
+ </div>
89
+ );
90
+ }
91
+
92
+ function SettingsDropdown({
93
+ onJoin,
94
+ volumeSlider,
95
+ onToggleMute,
96
+ isMobile = false,
97
+ tableId,
98
+ className,
99
+ }: {
100
+ onJoin: (tableIdOrLink: string) => void;
101
+ volumeSlider: {
102
+ globalVolume: number;
103
+ setGlobalVolume: (volume: number) => void;
104
+ setPrevVolume: (volume: number) => void;
105
+ };
106
+ onToggleMute: () => void;
107
+ isMobile?: boolean;
108
+ tableId: string;
109
+ className?: string;
110
+ }) {
111
+ const [switchTableOpen, setSwitchTableOpen] = useState(false);
112
+
113
+ return (
114
+ <>
115
+ <DropdownMenu>
116
+ <DropdownMenuTrigger
117
+ type="button"
118
+ className={cn(
119
+ "flex cursor-pointer items-center gap-1 h-5 text-base font-semibold text-text-secondary hover:text-white active:text-white tracking-tight ease-in-out duration-300",
120
+ className,
121
+ )}
122
+ >
123
+ <Icon icon="settings" className="size-4" />
124
+ <span className="hidden lg:inline-block">Settings</span>
125
+ </DropdownMenuTrigger>
126
+
127
+ <DropdownMenuContent
128
+ side="top"
129
+ align="start"
130
+ sideOffset={12}
131
+ className="w-[220px] rounded-lg border border-surface-300 bg-surface-450 p-2 shadow-[0px_6px_22px_0px_rgba(0,0,0,0.3)]"
132
+ onCloseAutoFocus={(e) => e.preventDefault()}
133
+ >
134
+ <VolumeSlider
135
+ globalVolume={volumeSlider.globalVolume}
136
+ setGlobalVolume={volumeSlider.setGlobalVolume}
137
+ setPrevVolume={volumeSlider.setPrevVolume}
138
+ onToggleMute={onToggleMute}
139
+ />
140
+
141
+ <button
142
+ type="button"
143
+ onClick={() => setSwitchTableOpen(true)}
144
+ className="flex w-full items-center gap-2 rounded-xl px-2 py-3 transition-colors hover:bg-surface-300"
145
+ >
146
+ <Icon icon="logout" className="size-4 text-text-secondary" />
147
+ <span className="text-sm font-semibold text-white">
148
+ Switch Table
149
+ </span>
150
+ </button>
151
+ </DropdownMenuContent>
152
+ </DropdownMenu>
153
+
154
+ <SwitchTableDialog
155
+ open={switchTableOpen}
156
+ onOpenChange={setSwitchTableOpen}
157
+ currentTableId={tableId}
158
+ onJoin={onJoin}
159
+ isMobile={isMobile}
160
+ />
161
+ </>
162
+ );
163
+ }
164
+
165
+ function InviteFriendsButton({
166
+ tableId,
167
+ shareBaseUrl,
168
+ className,
169
+ }: {
170
+ tableId: string;
171
+ shareBaseUrl: string | null;
172
+ className?: string;
173
+ }) {
174
+ const [copied, setCopied] = useState(false);
175
+
176
+ const shareUrl = useMemo(() => {
177
+ if (!shareBaseUrl || !tableId) return null;
178
+ return `${shareBaseUrl}?tableId=${tableId}`;
179
+ }, [shareBaseUrl, tableId]);
180
+
181
+ const roomDisplay = useMemo(() => {
182
+ if (!shareUrl) return null;
183
+ try {
184
+ const url = new URL(shareUrl);
185
+ return `${url.host}${url.pathname}${url.search}`;
186
+ } catch {
187
+ return shareUrl;
188
+ }
189
+ }, [shareUrl]);
190
+
191
+ const handleCopy = useCallback(async () => {
192
+ if (!shareUrl) return;
193
+ try {
194
+ await navigator.clipboard.writeText(shareUrl);
195
+ setCopied(true);
196
+ setTimeout(() => setCopied(false), 2000);
197
+ } catch (err) {
198
+ console.error("Failed to copy:", err);
199
+ }
200
+ }, [shareUrl]);
201
+
202
+ if (!shareBaseUrl) return null;
203
+
204
+ return (
205
+ <div className={cn("flex flex-col lg:items-end", className)}>
206
+ <button
207
+ type="button"
208
+ onClick={handleCopy}
209
+ className="flex cursor-pointer h-5 items-center gap-1 text-base font-semibold tracking-tight group"
210
+ >
211
+ {copied ? (
212
+ <>
213
+ <Icon icon="check" className="size-4 text-accent-success" />
214
+ <span className="text-accent-success">Table Link Copied</span>
215
+ </>
216
+ ) : (
217
+ <>
218
+ <Icon icon="share" className="size-4" />
219
+ <span className="text-text-secondary! group-active:text-white">
220
+ Invite Friends
221
+ </span>
222
+ </>
223
+ )}
224
+ </button>
225
+ {roomDisplay && (
226
+ <p className="text-[10px] leading-3.5 text-text-tertiary h-3.5 truncate lg:w-fit w-50">
227
+ Table Id: {tableId}
228
+ </p>
229
+ )}
230
+ </div>
231
+ );
232
+ }
233
+
234
+ interface GameInfoButtonProps {
235
+ badges: {
236
+ houseEdge: string;
237
+ minBet: string;
238
+ coinInfo?: CoinInfo;
239
+ };
240
+ tabsContents: {
241
+ table: React.ReactNode;
242
+ howToPlay: React.ReactNode;
243
+ balance: React.ReactNode;
244
+ };
245
+ isMobile?: boolean;
246
+ className?: string;
247
+ gameTitle?: string;
248
+ gameDescription?: string;
249
+ }
250
+
251
+ function GameInfoButton({
252
+ badges,
253
+ tabsContents,
254
+ isMobile,
255
+ className,
256
+ gameTitle = "Blackjack",
257
+ gameDescription = "Classic Blackjack. Play solo or join a multiplayer table in real time.",
258
+ }: GameInfoButtonProps) {
259
+ const [open, setOpen] = useState(false);
260
+
261
+ return (
262
+ <>
263
+ <button
264
+ type="button"
265
+ onClick={() => setOpen(true)}
266
+ className={cn(
267
+ "flex cursor-pointer items-center gap-1 text-base font-semibold text-text-secondary hover:text-white active:text-white tracking-tight ease-in-out duration-300",
268
+ className,
269
+ )}
270
+ >
271
+ <Icon icon="rules" className="size-4" />
272
+ <span className="hidden lg:inline-block">Game Info</span>
273
+ </button>
274
+ <GameInfoDialog
275
+ open={open}
276
+ onOpenChange={setOpen}
277
+ title={gameTitle}
278
+ description={gameDescription}
279
+ badges={badges}
280
+ tabsContents={tabsContents}
281
+ footer={
282
+ <Button
283
+ variant="primary"
284
+ type="button"
285
+ onClick={() => setOpen(false)}
286
+ className="w-full h-10 rounded-xl"
287
+ >
288
+ Start Playing
289
+ </Button>
290
+ }
291
+ isMobile={isMobile}
292
+ />
293
+ </>
294
+ );
295
+ }
296
+
297
+ function GameFooter({
298
+ tableId,
299
+ shareBaseUrl,
300
+ badges,
301
+ tabsContents,
302
+ onJoin,
303
+ onToggleMute,
304
+ isMobile = false,
305
+ volumeSlider,
306
+ gameTitle,
307
+ gameDescription,
308
+ centerLogoUrl,
309
+ }: GameFooterProps) {
310
+ return (
311
+ <div className="relative w-full h-16.5 overflow-clip rounded-b-xl border-t-[5px] border-surface-450 bg-surface-500 p-4 lg:px-7 lg:py-4 flex items-center justify-between">
312
+ {/* Left: Settings + Game Info */}
313
+ <div className="flex items-center gap-7 order-2 lg:order-1">
314
+ <SettingsDropdown
315
+ onJoin={onJoin}
316
+ volumeSlider={volumeSlider}
317
+ onToggleMute={onToggleMute}
318
+ isMobile={isMobile}
319
+ tableId={tableId}
320
+ className="order-2 lg:order-1"
321
+ />
322
+
323
+ <GameInfoButton
324
+ badges={badges}
325
+ tabsContents={tabsContents}
326
+ isMobile={isMobile}
327
+ className="order-1 lg:order-2"
328
+ gameTitle={gameTitle}
329
+ gameDescription={gameDescription}
330
+ />
331
+ </div>
332
+
333
+ {/* Center: DU logo */}
334
+ {centerLogoUrl && (
335
+ <div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 hidden lg:block">
336
+ <img
337
+ src={centerLogoUrl}
338
+ alt="Center Logo"
339
+ className="h-16 w-40 opacity-50"
340
+ draggable={false}
341
+ />
342
+ </div>
343
+ )}
344
+
345
+
346
+ {/* Right: Invite Friends */}
347
+ <InviteFriendsButton
348
+ tableId={tableId}
349
+ shareBaseUrl={shareBaseUrl}
350
+ className="order-1 lg:order-2"
351
+ />
352
+ </div>
353
+ );
354
+ }
355
+
356
+ export { GameFooter };