Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 34 additions & 23 deletions components/game/GameFlowManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,38 @@ export default function GameFlowManager() {
setPhase('settings')
}

// ── THE PRIVY GATE, AHEAD OF EVERYTHING ───────────────────────────────────
//
// Above `phase === 'menu'` on purpose, and it was BELOW it in the first
// version — which is the whole bug the owner hit: the map is what 'menu'
// renders, so the gate could not appear until something moved the phase off
// it, and the login turned up AFTER the world map instead of before it.
//
// OWNER: *"harusnya sebelum user membuka maps, jd di awal banget persis
// setelah user klik Play game di landing page."*
//
// Nothing else may render first. This is the first screen of the session.
if (showPrivyGate) {
return (
<PrivyGate
onAuthenticated={async (uid, label) => {
// Through the SAME adopt() the Firebase screen uses: derive an
// address from the id, carry the guest's progress onto it, store the
// identity. Nothing about Privy is special here — adopt() only ever
// wanted a stable id, and this is one. Without this the screen's own
// promise ("it follows you anywhere") would be false.
try {
await acct.adopt(uid, label)
} finally {
// Even a failed migration must not trap the player on this screen.
setGateDone(true)
}
}}
onSkip={() => { acct.rememberSkipped(); setGateDone(true) }}
/>
)
}

// PHASE: MENU
if (phase === 'menu') {
// Same props for both — the hub is a drop-in swap for the classic menu.
Expand Down Expand Up @@ -742,29 +774,8 @@ export default function GameFlowManager() {
}

// PHASE: SIGN IN — offered once, before the name, and only to the players
// who have nothing keeping their progress. See shouldOfferSignIn().
// Ahead of every other phase: this is the first thing after the splash.
if (showPrivyGate) {
return (
<PrivyGate
onAuthenticated={async (uid, label) => {
// Through the SAME adopt() the Firebase screen uses: derive an
// address from the id, carry the guest's progress onto it, store the
// identity. Nothing about Privy is special here — adopt() only ever
// wanted a stable id, and this is one. Without this the screen's own
// promise ("it follows you anywhere") would be false.
try {
await acct.adopt(uid, label)
} finally {
// Even a failed migration must not trap the player on this screen.
setGateDone(true)
}
}}
onSkip={() => { acct.rememberSkipped(); setGateDone(true) }}
/>
)
}

// who have nothing keeping their progress. See shouldOfferSignIn(). Off
// entirely while the Privy gate is on; see goPlaying().
if (phase === 'sign-in') {
return (
<SignInScreen
Expand Down
149 changes: 110 additions & 39 deletions components/game/PrivyGate.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
'use client'

import { useCallback, useEffect, useRef, useState } from 'react'
import { PrivyProvider, usePrivy } from '@privy-io/react-auth'
import { PrivyProvider, usePrivy, useLoginWithOAuth } from '@privy-io/react-auth'
import { privyAppId } from '@/lib/privyGateFlag'
import { GoogleMark, MailMark, WalletMark } from './SignInMarks'
import '@/styles/signin.css'

// ─── One button that is both a wallet and a Google login ─────────────────────
// ─── Wallet and Google, on one screen ────────────────────────────────────────
//
// OWNER: *"aku mau ada koneksi wallet barengan dengan login google, muncul
// setelah loading/splash pertama, pake privy aja."*
// OWNER: *"harusnya langsung munculkan Login gmail, email, wallet."*
//
// Privy's own modal does both in one flow, which is the whole reason it is here:
// the screen it replaces had three buttons (Google, email, wallet) and made the
// player choose a mechanism before they knew what any of them were for.
// The first version had ONE button that opened Privy's modal and let the player
// choose inside it. That was wrong twice over. It hid the three things the
// screen exists to offer behind a press — a player who wants Google cannot see
// that Google is on offer — and it also shipped with class names this app's
// stylesheet does not define (`ns-signin-card`, `ns-signin-sub`), so it rendered
// as unstyled text on a black rectangle. Both are fixed here by using the SAME
// markup and the SAME stylesheet as SignInScreen: the bracket frame, the plate
// fill, the white Google button, the bevelled clips.
//
// Each button opens Privy's modal already narrowed to one method, so the choice
// is made HERE, on a screen that looks like the game, and Privy only handles the
// mechanics of the method that was chosen.
//
// ── WHY THIS FILE EXISTS AT ALL, RATHER THAN A PROVIDER AT THE ROOT ─────────
//
Expand All @@ -24,7 +33,9 @@ import '@/styles/signin.css'
//
// So the provider is mounted HERE, inside a component that is itself lazily
// imported, and only rendered when the gate is on. With the flag off — the
// default, and the state this ships in — nothing in this file is downloaded.
// default — nothing in this file is downloaded.

type Method = 'google' | 'email' | 'wallet'

interface PrivyGateProps {
/**
Expand All @@ -47,7 +58,8 @@ interface PrivyGateProps {
/** The inner half: everything that needs the Privy context. */
function GateInner({ onAuthenticated, onSkip }: PrivyGateProps) {
const { ready, authenticated, user, login } = usePrivy()
const [busy, setBusy] = useState(false)
const { initOAuth } = useLoginWithOAuth()
const [busy, setBusy] = useState<Method | null>(null)
const adopted = useRef(false)

// Privy restores an existing session asynchronously. Someone who already
Expand All @@ -67,48 +79,107 @@ function GateInner({ onAuthenticated, onSkip }: PrivyGateProps) {
)
}, [ready, authenticated, user, onAuthenticated])

const start = useCallback(() => {
setBusy(true)
const start = useCallback((method: Method) => {
setBusy(method)
// Narrowed to the one method the player pressed. Privy's modal would
// otherwise re-ask the question this screen just answered.
login({ loginMethods: [method] })
// The modal belongs to Privy, so there is no promise to await. Clear the
// pending state shortly after it has been asked to open, or a dismissed
// modal would leave the button stuck reading "Opening…" forever.
setTimeout(() => setBusy(null), 1500)
}, [login])

// ── GOOGLE SKIPS PRIVY'S SCREEN ENTIRELY ──────────────────────────────────
//
// OWNER: *"itu kalo aku klik google, ga muncul 2x pop up kan? layar privy
// juga maksudku."* It did. `login({loginMethods:['google']})` opens Privy's
// modal showing a single Google button — the same question this screen just
// asked, asked again — and only then hands off to Google.
//
// initOAuth goes straight to Google with no Privy UI at all, so the tap on
// "Continue with Google" is the last thing before Google's own account
// chooser. It is a full-page redirect rather than a popup, which is also the
// right shape on a phone: popups are what mobile browsers block.
//
// Privy marks the hook @experimental, so it cannot be the only path. If it
// throws, this falls back to the modal — one extra screen is a worse login,
// an unrecoverable one is no login at all.
const startGoogle = useCallback(async () => {
setBusy('google')
try {
// Privy owns the modal from here: Google, email and wallet all live
// inside it, so there is no second choice to present.
login()
} finally {
// The modal is Privy's, so there is no promise to await — clear the
// pending state once it has been asked to open, or a dismissed modal
// would leave the button stuck.
setTimeout(() => setBusy(false), 1200)
await initOAuth({ provider: 'google' })
} catch {
login({ loginMethods: ['google'] })
setTimeout(() => setBusy(null), 1500)
}
}, [login])
// No success branch clears `busy` on purpose: the page is navigating to
// Google, and a button that flips back to "Continue with Google" while the
// redirect is in flight invites a second tap.
}, [initOAuth, login])

const disabled = !ready || busy !== null

return (
<div className="ns-signin-root">
<div className="ns-signin-card">
<p className="ns-signin-kicker">// SAVE YOUR PROGRESS</p>
<h1 className="ns-signin-title">KEEP YOUR RUN</h1>
<p className="ns-signin-sub">
<div className="ns-signin-orb" aria-hidden="true" />

<div className="ns-signin-panel">
{/* Corner brackets, the same device the world map uses to mark a
selected bunker. They are what stops this reading as a web form
dropped into a game. */}
<span className="ns-signin-frame" aria-hidden="true" />

<div className="ns-signin-kicker">{'// SAVE YOUR PROGRESS'}</div>

<h2 className="ns-signin-title">KEEP YOUR RUN</h2>

<p className="ns-signin-lede">
Your run lives in this browser only. Sign in and it follows you anywhere.
</p>

<button
type="button"
onClick={start}
disabled={!ready || busy}
className="ns-signin-btn ns-signin-btn-primary"
>
<span>{!ready ? 'Loading…' : busy ? 'Opening…' : 'Sign in'}</span>
</button>
<div className="ns-signin-stack">
<button
type="button"
onClick={startGoogle}
disabled={disabled}
className="ns-signin-btn is-google"
>
<span className="ns-signin-ico"><GoogleMark /></span>
<span>{busy === 'google' ? 'Opening…' : 'Continue with Google'}</span>
</button>

<button
type="button"
onClick={() => start('email')}
disabled={disabled}
className="ns-signin-btn"
>
<span className="ns-signin-ico"><MailMark /></span>
<span>{busy === 'email' ? 'Opening…' : 'Continue with email'}</span>
</button>

{/* Never the phrase check:copy bans — and this screen is never shown
inside MiniPay anyway. It also says the true thing: this button is
for people who ALREADY have one, not an instruction to go get one. */}
<button
type="button"
onClick={() => start('wallet')}
disabled={disabled}
className="ns-signin-btn"
>
<span className="ns-signin-ico"><WalletMark /></span>
<span>{busy === 'wallet' ? 'Opening…' : 'I already have a wallet'}</span>
</button>
</div>

<button type="button" onClick={onSkip} className="ns-signin-skip">
Skip for now →
</button>

<p className="ns-signin-foot">
{/* Deliberately not the phrase MiniPay's copy rules ban — and this
screen is never shown inside MiniPay anyway. It describes what the
player gets, not the machinery they have to operate. */}
Signing in saves your name and progress across devices. Buying items and
claiming rewards needs an account — you can add one later.
An account saves your name and progress across devices. Buying items and
claiming rewards needs a wallet — you can add one later.
</p>
</div>
</div>
Expand All @@ -127,8 +198,8 @@ export default function PrivyGate(props: PrivyGateProps) {
<PrivyProvider
appId={appId}
config={{
// Both, in one modal — that is the entire point of the change.
loginMethods: ['google', 'wallet', 'email'],
// All three, so each button's narrowed call has something to narrow to.
loginMethods: ['google', 'email', 'wallet'],
appearance: { theme: 'dark', accentColor: '#39ff9a' },
// An embedded wallet for anyone who arrives without one, so a Google
// sign-in still ends with an address the rewards system can pay.
Expand Down
47 changes: 47 additions & 0 deletions components/game/SignInMarks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
'use client'

// ─── The three sign-in marks, in one place ───────────────────────────────────
//
// Lifted out of SignInScreen when the Privy gate started needing the same three
// buttons. Two copies of the Google mark is two chances for one of them to be
// quietly recoloured or resized until the two screens no longer look like the
// same product — and Google's brand guidelines are specific enough that a drift
// here is a compliance problem, not a taste one.
//
// Inline SVG rather than files: a strict CSP blocks external images, and these
// must never cost a request on a screen whose whole job is to appear instantly.

/**
* Google's mark. It has to be the real four-colour G — a monochrome or
* recoloured version is off-guideline, and players recognise the actual one at
* a glance, which is the entire point of putting it here.
*/
export function GoogleMark() {
return (
<svg viewBox="0 0 48 48" width="20" height="20" aria-hidden="true" focusable="false">
<path fill="#4285F4" d="M45.12 24.5c0-1.56-.14-3.06-.4-4.5H24v8.51h11.84c-.51 2.75-2.06 5.08-4.39 6.64v5.52h7.11c4.16-3.83 6.56-9.47 6.56-16.17z" />
<path fill="#34A853" d="M24 46c5.94 0 10.92-1.97 14.56-5.33l-7.11-5.52c-1.97 1.32-4.49 2.1-7.45 2.1-5.73 0-10.58-3.87-12.31-9.07H4.34v5.7C7.96 41.07 15.4 46 24 46z" />
<path fill="#FBBC05" d="M11.69 28.18C11.25 26.86 11 25.45 11 24s.25-2.86.69-4.18v-5.7H4.34C2.85 17.09 2 20.45 2 24s.85 6.91 2.34 9.88l7.35-5.7z" />
<path fill="#EA4335" d="M24 10.75c3.23 0 6.13 1.11 8.41 3.29l6.31-6.31C34.91 4.18 29.93 2 24 2 15.4 2 7.96 6.93 4.34 14.12l7.35 5.7c1.73-5.2 6.58-9.07 12.31-9.07z" />
</svg>
)
}

export function MailMark() {
return (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true" focusable="false">
<rect x="2.5" y="5" width="19" height="14" rx="1.5" />
<path d="M3 6.5 12 13l9-6.5" />
</svg>
)
}

export function WalletMark() {
return (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true" focusable="false">
<path d="M3 7.5A2.5 2.5 0 0 1 5.5 5H18a1 1 0 0 1 1 1v2" />
<rect x="3" y="7.5" width="18" height="12" rx="2" />
<circle cx="16.5" cy="13.5" r="1.4" fill="currentColor" stroke="none" />
</svg>
)
}
35 changes: 1 addition & 34 deletions components/game/SignInScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useState } from 'react'
// Its own stylesheet, not styles/game.css — that one blocks the world map's
// first paint and this screen is two taps away. See the header in signin.css.
import { GoogleMark, MailMark, WalletMark } from './SignInMarks'
import '@/styles/signin.css'

// ─── Save-your-progress screen ───────────────────────────────────────────────
Expand Down Expand Up @@ -35,40 +36,6 @@ import '@/styles/signin.css'

export type SignInMethod = 'google' | 'email' | 'wallet'

// Google's mark, inlined. It has to be the real four-colour G — a monochrome
// or recoloured version is off-guideline, and players recognise the actual one
// at a glance, which is the entire point of putting it here. Inline because a
// strict CSP blocks external images and this must never cost a request.
function GoogleMark() {
return (
<svg viewBox="0 0 48 48" width="20" height="20" aria-hidden="true" focusable="false">
<path fill="#4285F4" d="M45.12 24.5c0-1.56-.14-3.06-.4-4.5H24v8.51h11.84c-.51 2.75-2.06 5.08-4.39 6.64v5.52h7.11c4.16-3.83 6.56-9.47 6.56-16.17z" />
<path fill="#34A853" d="M24 46c5.94 0 10.92-1.97 14.56-5.33l-7.11-5.52c-1.97 1.32-4.49 2.1-7.45 2.1-5.73 0-10.58-3.87-12.31-9.07H4.34v5.7C7.96 41.07 15.4 46 24 46z" />
<path fill="#FBBC05" d="M11.69 28.18C11.25 26.86 11 25.45 11 24s.25-2.86.69-4.18v-5.7H4.34C2.85 17.09 2 20.45 2 24s.85 6.91 2.34 9.88l7.35-5.7z" />
<path fill="#EA4335" d="M24 10.75c3.23 0 6.13 1.11 8.41 3.29l6.31-6.31C34.91 4.18 29.93 2 24 2 15.4 2 7.96 6.93 4.34 14.12l7.35 5.7c1.73-5.2 6.58-9.07 12.31-9.07z" />
</svg>
)
}

function MailMark() {
return (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true" focusable="false">
<rect x="2.5" y="5" width="19" height="14" rx="1.5" />
<path d="M3 6.5 12 13l9-6.5" />
</svg>
)
}

function WalletMark() {
return (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true" focusable="false">
<path d="M3 7.5A2.5 2.5 0 0 1 5.5 5H18a1 1 0 0 1 1 1v2" />
<rect x="3" y="7.5" width="18" height="12" rx="2" />
<circle cx="16.5" cy="13.5" r="1.4" fill="currentColor" stroke="none" />
</svg>
)
}

interface SignInScreenProps {
onGoogle: () => Promise<void>
onEmail: (email: string) => Promise<void>
Expand Down
Loading
Loading