Skip to content

Commit 548fe3e

Browse files
fix(remote): hydrate authoritative room participants and scale mobile typography
1 parent 66b2a2d commit 548fe3e

5 files changed

Lines changed: 154 additions & 44 deletions

File tree

‎App.tsx‎

Lines changed: 68 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ const App: React.FC = () => {
6464
switch (event.type) {
6565
case 'START':
6666
soundService.playDrop();
67+
if (event.participants && event.participants.length > 0) {
68+
setFounders(buildParticipants(event.participantCount ?? event.participants.length, event.participants));
69+
}
6770
setGameState({
6871
currentPrice: event.startPrice,
6972
status: AuctionStatus.RUNNING,
@@ -144,7 +147,13 @@ const App: React.FC = () => {
144147

145148
if (isRemote) {
146149
try {
147-
await syncService.sendEvent({ type: 'START', startTime, startPrice: config.startPrice });
150+
await syncService.sendEvent({
151+
type: 'START',
152+
startTime,
153+
startPrice: config.startPrice,
154+
participantCount: founders.length,
155+
participants: founders.map((f) => ({ id: f.id, name: f.name, color: f.color })),
156+
});
148157
} catch (err) {
149158
showError(err);
150159
}
@@ -212,27 +221,13 @@ const App: React.FC = () => {
212221
};
213222

214223
useEffect(() => {
215-
let timer: number | null = null;
216-
217-
const refreshClaims = async () => {
218-
if (!isRemote || !isConnected) return;
219-
const claimed = await syncService.listClaimedParticipants();
220-
setClaimedIds(claimed);
221-
};
222-
223-
void refreshClaims();
224-
if (isRemote && isConnected) {
225-
timer = window.setInterval(() => {
226-
void refreshClaims();
227-
}, 3000);
228-
}
229-
224+
syncService.onParticipantsChanged = isRemote && isConnected ? (claimed) => setClaimedIds(claimed) : null;
230225
return () => {
231-
if (timer) window.clearInterval(timer);
226+
syncService.onParticipantsChanged = null;
232227
};
233228
}, [isRemote, isConnected]);
234229

235-
const applySetup = () => {
230+
const applySetup = async () => {
236231
const nextConfig: AuctionConfig = {
237232
startPrice: Math.max(1, Math.floor(draftConfig.startPrice)),
238233
floorPrice: Math.max(1, Math.floor(draftConfig.floorPrice)),
@@ -264,6 +259,15 @@ const App: React.FC = () => {
264259
history: [],
265260
nextDropTime: 0,
266261
});
262+
263+
if (isRemote && isConnected && isHost) {
264+
try {
265+
await syncService.publishRoomConfig(nextFounders, nextFounders.length);
266+
} catch (err) {
267+
showError(err);
268+
}
269+
}
270+
267271
setSetupOpen(false);
268272
};
269273

@@ -286,23 +290,46 @@ const App: React.FC = () => {
286290
</div>
287291

288292
<div className="flex items-center gap-2 md:gap-4">
289-
<button disabled={(isRemote && isConnected) || gameState.status === AuctionStatus.RUNNING || (isRemote && !isHost)} onClick={() => { setDraftConfig(config); setDraftInitials(founders.map((f) => f.name).join(', ')); setSetupOpen(true); }} className="text-[9px] md:text-[10px] uppercase font-bold text-slate-500 hover:text-amber-300 disabled:opacity-40 disabled:cursor-not-allowed transition-colors">
293+
<button disabled={(isRemote && isConnected) || gameState.status === AuctionStatus.RUNNING || (isRemote && !isHost)} onClick={() => { setDraftConfig(config); setDraftInitials(founders.map((f) => f.name).join(', ')); setSetupOpen(true); }} className="text-[11px] md:text-xs uppercase font-bold text-slate-400 hover:text-amber-300 disabled:opacity-40 disabled:cursor-not-allowed transition-colors">
290294
Setup
291295
</button>
292296
{!isRemote ? (
293-
<button onClick={() => setIsRemote(true)} className="text-[9px] md:text-[10px] uppercase font-bold text-slate-500 hover:text-cyan-400 transition-colors">
297+
<button onClick={() => setIsRemote(true)} className="text-[11px] md:text-xs uppercase font-bold text-slate-400 hover:text-cyan-400 transition-colors">
294298
Go Remote
295299
</button>
296300
) : (
297301
<div className="flex items-center gap-2">
298302
{!isConnected ? (
299303
<div className="flex gap-1">
300-
<input type="text" placeholder="Room" className="bg-slate-800 border border-slate-700 rounded px-2 py-1 text-[10px] w-16 md:w-24 outline-none focus:border-cyan-500" value={roomCode} onChange={(e) => setRoomCode(e.target.value)} />
301-
<button onClick={async () => { if (!roomCode.trim()) { showError('Please enter a room code.'); return; } await syncService.joinRoom(roomCode, handleRemoteEvent); setIsConnected(true); setIsHost(syncService.isHost()); setClaimedIds(await syncService.listClaimedParticipants()); }} className="bg-cyan-600 px-2 py-1 rounded text-[10px] font-bold">JOIN</button>
304+
<input type="text" placeholder="Room code" className="bg-slate-800 border border-slate-700 rounded px-2 py-1.5 text-xs w-28 md:w-32 outline-none focus:border-cyan-500" value={roomCode} onChange={(e) => setRoomCode(e.target.value)} />
305+
<button onClick={async () => {
306+
if (!roomCode.trim()) {
307+
showError('Please enter a room code.');
308+
return;
309+
}
310+
try {
311+
const roomInfo = await syncService.joinRoom(roomCode, handleRemoteEvent);
312+
const amHost = syncService.isHost();
313+
setIsConnected(true);
314+
setIsHost(amHost);
315+
316+
if (!amHost && roomInfo.participants && roomInfo.participants.length > 0) {
317+
setFounders(buildParticipants(roomInfo.participantCount ?? roomInfo.participants.length, roomInfo.participants));
318+
}
319+
320+
if (amHost) {
321+
await syncService.publishRoomConfig(founders, founders.length);
322+
}
323+
324+
setClaimedIds(await syncService.listClaimedParticipants());
325+
} catch (err) {
326+
showError(err);
327+
}
328+
}} className="bg-cyan-600 px-2 py-1 rounded text-[10px] md:text-xs font-bold">JOIN</button>
302329
</div>
303330
) : (
304331
<div className="flex items-center gap-2">
305-
<span className="text-[9px] md:text-[10px] text-cyan-300 font-mono uppercase">Room: {roomCode}</span>
332+
<span className="text-[11px] md:text-xs text-cyan-300 font-mono uppercase">Room: {roomCode}</span>
306333
<button
307334
onClick={async () => {
308335
try {
@@ -313,11 +340,11 @@ const App: React.FC = () => {
313340
showError('Could not copy room code.');
314341
}
315342
}}
316-
className="text-[9px] md:text-[10px] text-cyan-400 uppercase font-bold"
343+
className="text-[11px] md:text-xs text-cyan-400 uppercase font-bold"
317344
>
318345
Copy
319346
</button>
320-
<button onClick={() => { syncService.leaveRoom(); setIsConnected(false); setIsHost(false); setClaimedIds(new Set()); setIsRemote(false); setMyFounderId(null); }} className="text-[9px] md:text-[10px] text-red-500 uppercase font-bold">Disconnect</button>
347+
<button onClick={() => { syncService.leaveRoom(); setIsConnected(false); setIsHost(false); setClaimedIds(new Set()); setIsRemote(false); setMyFounderId(null); }} className="text-[11px] md:text-xs text-red-500 uppercase font-bold">Disconnect</button>
321348
</div>
322349
)}
323350
</div>
@@ -373,7 +400,7 @@ const App: React.FC = () => {
373400

374401
<div className="mt-5 flex justify-end gap-2">
375402
<button onClick={() => { setDraftConfig(config); setDraftInitials(founders.map((f) => f.name).join(', ')); setSetupOpen(false); }} className="px-3 py-2 rounded border border-slate-700">Cancel</button>
376-
<button onClick={applySetup} className="px-3 py-2 rounded bg-cyan-500 text-slate-900 font-bold">Apply & Reset</button>
403+
<button onClick={() => { void applySetup(); }} className="px-3 py-2 rounded bg-cyan-500 text-slate-900 font-bold">Apply & Reset</button>
377404
</div>
378405
</div>
379406
</div>
@@ -415,7 +442,19 @@ const App: React.FC = () => {
415442
</button>
416443
<div className="grid grid-cols-1 gap-3">{founders.map((f) => {
417444
const alreadyClaimed = claimedIds.has(f.id);
418-
return <button key={f.id} disabled={alreadyClaimed} onClick={async () => { const ok = await syncService.claimParticipant(f.id); if (ok) { setMyFounderId(f.id); setClaimedIds(await syncService.listClaimedParticipants()); } else { showError('This participant is already claimed. Choose another.'); } }} className={`p-3 md:p-4 rounded-xl border-2 border-slate-800 transition-all font-bold text-base md:text-lg ${f.color.replace('bg-', 'text-')} ${alreadyClaimed ? 'opacity-40 cursor-not-allowed' : 'hover:border-cyan-500'}`}>
445+
return <button key={f.id} disabled={alreadyClaimed} onClick={async () => {
446+
try {
447+
const ok = await syncService.claimParticipant(f.id);
448+
if (ok) {
449+
setMyFounderId(f.id);
450+
setClaimedIds(await syncService.listClaimedParticipants());
451+
} else {
452+
showError('This participant is already claimed. Choose another.');
453+
}
454+
} catch (err) {
455+
showError(err);
456+
}
457+
}} className={`p-3 md:p-4 rounded-xl border-2 border-slate-800 transition-all font-bold text-base md:text-lg ${f.color.replace('bg-', 'text-')} ${alreadyClaimed ? 'opacity-40 cursor-not-allowed' : 'hover:border-cyan-500'}`}>
419458
<div>{f.name}</div>
420459
<div className="text-[10px] mt-1 text-slate-400">{alreadyClaimed ? 'Already joined' : 'Available'}</div>
421460
</button>;
@@ -427,9 +466,9 @@ const App: React.FC = () => {
427466
<main className="flex-1 flex flex-col md:flex-row overflow-hidden">
428467
<div className="flex-1 flex flex-col p-3 md:p-6 relative min-w-0">
429468
<div className="flex-none mb-2 border-b border-slate-800 pb-2">
430-
<h3 className="text-[8px] md:text-xs font-bold text-slate-600 uppercase tracking-widest mb-1 md:mb-3">History</h3>
469+
<h3 className="text-[10px] md:text-xs font-bold text-slate-500 uppercase tracking-widest mb-1 md:mb-3">History</h3>
431470
<div className="flex gap-1.5 md:gap-2 overflow-x-auto pb-1 mask-linear scrollbar-hide">
432-
{gameState.history.slice().reverse().map((log, idx) => <div key={idx} className="flex-shrink-0 px-2 py-1 rounded bg-slate-900 border border-slate-800 text-[9px] md:text-[10px] font-mono whitespace-nowrap"><span className={log.event === 'WIN' ? 'text-green-400' : 'text-slate-400'}>${log.price.toLocaleString()}</span><span className="mx-1 opacity-20">|</span><span className="opacity-70">{log.event === 'WIN' ? log.details : log.event}</span></div>)}
471+
{gameState.history.slice().reverse().map((log, idx) => <div key={idx} className="flex-shrink-0 px-2 py-1 rounded bg-slate-900 border border-slate-800 text-xs md:text-sm font-mono whitespace-nowrap"><span className={log.event === 'WIN' ? 'text-green-400' : 'text-slate-300'}>${log.price.toLocaleString()}</span><span className="mx-1 opacity-20">|</span><span className="opacity-75">{log.event === 'WIN' ? log.details : log.event}</span></div>)}
433472
</div>
434473
</div>
435474

‎components/FounderButton.tsx‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,38 +45,38 @@ export const FounderButton: React.FC<FounderButtonProps> = ({
4545
className={`
4646
relative group flex flex-col items-center justify-center p-2.5 md:p-4
4747
rounded-xl md:rounded-2xl border-2 w-full transition-all duration-200
48-
min-h-[100px] md:min-h-[140px]
48+
min-h-[124px] md:min-h-[140px]
4949
${buttonStyle}
5050
`}
5151
>
5252
{isWinner && (
53-
<div className="absolute -top-1.5 md:-top-2.5 px-1.5 py-0.5 md:px-2 md:py-1 bg-white text-black text-[7px] md:text-[10px] font-bold uppercase tracking-widest rounded-full shadow-lg z-10">
53+
<div className="absolute -top-1.5 md:-top-2.5 px-1.5 py-0.5 md:px-2 md:py-1 bg-white text-black text-[9px] md:text-[10px] font-bold uppercase tracking-widest rounded-full shadow-lg z-10">
5454
Winner
5555
</div>
5656
)}
5757

5858
{isMe && !isWinner && (
59-
<div className="absolute -top-1.5 md:-top-2.5 px-1.5 py-0.5 md:px-2 md:py-1 bg-cyan-500 text-white text-[7px] md:text-[10px] font-bold uppercase tracking-widest rounded-full shadow-lg z-10 border border-white/10">
59+
<div className="absolute -top-1.5 md:-top-2.5 px-1.5 py-0.5 md:px-2 md:py-1 bg-cyan-500 text-white text-[9px] md:text-[10px] font-bold uppercase tracking-widest rounded-full shadow-lg z-10 border border-white/10">
6060
YOU
6161
</div>
6262
)}
6363

6464
{/* Initials Circle */}
6565
<div className={`
66-
w-8 h-8 md:w-16 md:h-16 rounded-full mb-1.5 md:mb-3 flex items-center justify-center border md:border-2 shadow-sm transition-transform duration-300
66+
w-11 h-11 md:w-16 md:h-16 rounded-full mb-1.5 md:mb-3 flex items-center justify-center border md:border-2 shadow-sm transition-transform duration-300
6767
${isWinner ? 'bg-white text-slate-900 border-white scale-105 md:scale-110' :
6868
disabled && !isWinner ? 'bg-slate-800 border-slate-700 text-slate-600' :
6969
`${founder.color} text-white border-white/5 group-hover:border-white/20`}
7070
`}>
71-
<span className="text-sm md:text-xl font-bold tracking-wider">{founder.name}</span>
71+
<span className="text-base md:text-xl font-bold tracking-wider">{founder.name}</span>
7272
</div>
7373

74-
<div className={`font-bold display-font uppercase ${isWinner ? 'text-[10px] md:text-lg tracking-normal' : 'text-[11px] md:text-base tracking-tight'}`}>
74+
<div className={`font-bold display-font uppercase ${isWinner ? 'text-xs md:text-lg tracking-normal' : 'text-sm md:text-base tracking-tight'}`}>
7575
{isWinner ? 'ACCEPTED' : isRunning ? (disabled ? 'WAIT' : 'ACCEPT') : 'IDLE'}
7676
</div>
7777

7878
{isRunning && !disabled && (
79-
<div className="mt-1 text-[10px] md:text-[10px] font-mono opacity-60">
79+
<div className="mt-1 text-xs md:text-sm font-mono opacity-70">
8080
${(currentPrice / 1000).toFixed(0)}k
8181
</div>
8282
)}

‎components/Ticker.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,13 @@ export const Ticker: React.FC<TickerProps> = ({ price, status, nextDropTime, dro
5151
</div>
5252

5353
<div className="relative z-10 text-center">
54-
<h2 className="text-slate-400 text-[11px] md:text-sm uppercase tracking-[0.16em] mb-1 md:mb-2 font-semibold">Current Price</h2>
55-
<div className="text-5xl sm:text-7xl md:text-9xl font-bold text-white tracking-tighter tabular-nums display-font leading-none">
54+
<h2 className="text-slate-300 text-xs md:text-sm uppercase tracking-[0.16em] mb-1 md:mb-2 font-semibold">Current Price</h2>
55+
<div className="text-6xl sm:text-7xl md:text-9xl font-bold text-white tracking-tighter tabular-nums display-font leading-none">
5656
${price.toLocaleString()}
5757
</div>
5858
</div>
5959

60-
<div className="w-32 md:w-64 h-1 md:h-2 bg-slate-800 rounded-full mt-3 md:mt-8 overflow-hidden relative">
60+
<div className="w-full max-w-sm md:w-64 h-3 md:h-2 bg-slate-800 rounded-full mt-4 md:mt-8 overflow-hidden relative">
6161
<div
6262
className={`h-full absolute top-0 left-0 transition-all duration-75 ease-linear ${
6363
progress < 30 ? 'bg-red-500' : 'bg-cyan-500'
@@ -67,7 +67,7 @@ export const Ticker: React.FC<TickerProps> = ({ price, status, nextDropTime, dro
6767
</div>
6868

6969
{status === AuctionStatus.RUNNING && (
70-
<p className="mt-2 text-[11px] md:text-xs text-slate-400 font-mono animate-pulse">
70+
<p className="mt-2 text-xs md:text-sm text-slate-300 font-mono animate-pulse">
7171
{Math.max(1, Math.ceil((progress / 100) * (dropIntervalMs / 1000)))}s
7272
</p>
7373
)}

‎services/syncService.ts‎

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createClient, type SupabaseClient } from '@supabase/supabase-js';
2-
import { AuctionStatus, SyncEvent } from '../types';
2+
import { AuctionStatus, Founder, SyncEvent, SyncParticipant } from '../types';
33

44
const runtimeSetup = (window as any).AUCTION_SETUP ?? {};
55

@@ -29,6 +29,13 @@ type RoomState = {
2929
updatedAt: number;
3030
eventSeq: number;
3131
status: AuctionStatus;
32+
participants?: SyncParticipant[];
33+
participantCount?: number;
34+
};
35+
36+
type JoinRoomResult = {
37+
participants: SyncParticipant[] | null;
38+
participantCount: number | null;
3239
};
3340

3441
type RoomMeta = {
@@ -41,6 +48,7 @@ class SyncService {
4148
private channel: ReturnType<SupabaseClient['channel']> | null = null;
4249
private currentUserId: string | null = null;
4350
private isHostForRoom = false;
51+
onParticipantsChanged: ((claimed: Set<string>) => void) | null = null;
4452

4553
async initAuth() {
4654
if (this.currentUserId) return this.currentUserId;
@@ -106,6 +114,41 @@ class SyncService {
106114
if (error) throw new Error(error.message);
107115
}
108116

117+
private async getRoomParticipants(roomId: string): Promise<SyncParticipant[]> {
118+
const state = await this.readRoomState(roomId);
119+
if (!state?.participants || state.participants.length === 0) return [];
120+
return state.participants
121+
.map((p, idx) => ({
122+
id: String(p?.id ?? idx + 1),
123+
name: String(p?.name ?? `P${idx + 1}`).slice(0, 10).toUpperCase(),
124+
color: String(p?.color ?? 'bg-cyan-500'),
125+
}))
126+
.filter((p) => Boolean(p.id) && Boolean(p.name));
127+
}
128+
129+
async publishRoomConfig(participants: Founder[], count: number) {
130+
if (!this.roomCode) return;
131+
const current =
132+
(await this.readRoomState(this.roomCode)) ??
133+
({ roomId: this.roomCode, updatedAt: Date.now(), eventSeq: 0, status: AuctionStatus.IDLE } as RoomState);
134+
135+
const normalized = participants.slice(0, Math.max(1, Math.floor(count))).map((p, idx) => ({
136+
id: String(p.id ?? idx + 1),
137+
name: String(p.name ?? `P${idx + 1}`).slice(0, 10).toUpperCase(),
138+
color: String(p.color ?? 'bg-cyan-500'),
139+
}));
140+
141+
const nextState: RoomState = {
142+
...current,
143+
roomId: this.roomCode,
144+
updatedAt: Date.now(),
145+
participants: normalized,
146+
participantCount: Math.max(1, Math.floor(count)),
147+
};
148+
149+
await this.writeRoomState(this.roomCode, nextState);
150+
}
151+
109152
async listClaimedParticipants(): Promise<Set<string>> {
110153
if (!this.roomCode) return new Set();
111154
const supabase = getSupabase();
@@ -124,6 +167,11 @@ class SyncService {
124167
const supabase = getSupabase();
125168
const userId = await this.initAuth();
126169

170+
const configured = await this.getRoomParticipants(this.roomCode);
171+
if (configured.length > 0 && !configured.some((p) => p.id === founderId)) {
172+
throw new Error('Invalid participant slot for this room.');
173+
}
174+
127175
await supabase.from('room_participants').upsert(
128176
{
129177
room_id: this.roomCode,
@@ -160,12 +208,14 @@ class SyncService {
160208
return q.data?.user_id === userId;
161209
}
162210

163-
async joinRoom(code: string, onEvent: (event: SyncEvent) => void) {
211+
async joinRoom(code: string, onEvent: (event: SyncEvent) => void): Promise<JoinRoomResult> {
164212
this.leaveRoom();
165213
const supabase = getSupabase();
166214

167215
const roomId = code.trim().toLowerCase().replace(/[^a-z0-9]/g, '');
168-
if (!roomId) return;
216+
if (!roomId) {
217+
return { participants: null, participantCount: null };
218+
}
169219
this.roomCode = roomId;
170220

171221
await this.getOrCreateRoomMeta(roomId);
@@ -191,7 +241,25 @@ class SyncService {
191241
if (ev) onEvent(ev);
192242
},
193243
)
244+
.on(
245+
'postgres_changes',
246+
{
247+
event: '*',
248+
schema: 'public',
249+
table: 'room_participants',
250+
filter: `room_id=eq.${roomId}`,
251+
},
252+
async () => {
253+
const claimed = await this.listClaimedParticipants();
254+
this.onParticipantsChanged?.(claimed);
255+
},
256+
)
194257
.subscribe();
258+
259+
return {
260+
participants: existing?.participants ?? null,
261+
participantCount: existing?.participantCount ?? null,
262+
};
195263
}
196264

197265
private deriveNextStatus(current: AuctionStatus, event: SyncEvent): AuctionStatus {
@@ -267,6 +335,7 @@ class SyncService {
267335
}
268336
this.roomCode = null;
269337
this.isHostForRoom = false;
338+
this.onParticipantsChanged = null;
270339
}
271340
}
272341

0 commit comments

Comments
 (0)