diff --git a/.claude/launch.json b/.claude/launch.json index 159605a..bd735da 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -4,40 +4,91 @@ { "name": "design", "runtimeExecutable": "python3", - "runtimeArgs": ["-m", "http.server", "4800", "--directory", "design"], + "runtimeArgs": [ + "-m", + "http.server", + "4800", + "--directory", + "design" + ], "port": 4800 }, { "name": "design-any-port", "runtimeExecutable": "sh", - "runtimeArgs": ["-c", "exec python3 -m http.server \"$PORT\" --directory design"], + "runtimeArgs": [ + "-c", + "exec python3 -m http.server \"$PORT\" --directory design" + ], "port": 4801, "autoPort": true }, { "name": "website", "runtimeExecutable": "npm", - "runtimeArgs": ["run", "dev:website"], + "runtimeArgs": [ + "run", + "dev:website" + ], "port": 4810 }, { "name": "mobile-web", "runtimeExecutable": "sh", - "runtimeArgs": ["-c", "cd apps/mobile && exec npx expo start --web --port 8081"], + "runtimeArgs": [ + "-c", + "cd apps/mobile && exec npx expo start --web --port 8081" + ], "port": 8081 }, { "name": "mobile-web-any-port", "runtimeExecutable": "sh", - "runtimeArgs": ["-c", "cd apps/mobile && exec npx expo start --web --port \"$PORT\""], + "runtimeArgs": [ + "-c", + "cd apps/mobile && exec npx expo start --web --port \"$PORT\"" + ], "port": 8082, "autoPort": true }, { "name": "mobile-ios", "runtimeExecutable": "sh", - "runtimeArgs": ["-c", "cd apps/mobile && exec npx expo start --ios --port 8081"], + "runtimeArgs": [ + "-c", + "cd apps/mobile && exec npx expo start --ios --port 8081" + ], "port": 8081 + }, + { + "name": "mobile-web-mock", + "runtimeExecutable": "sh", + "runtimeArgs": [ + "-c", + "cd apps/mobile && EXPO_PUBLIC_API_MODE=mock exec npx expo start --web --port \"$PORT\"" + ], + "port": 8090, + "autoPort": true + }, + { + "name": "mobile-web-mock-watch", + "runtimeExecutable": "sh", + "runtimeArgs": [ + "-c", + "cd apps/mobile && EXPO_PUBLIC_API_MODE=mock exec npx expo start --web --port \"$PORT\"" + ], + "port": 8093, + "autoPort": true + }, + { + "name": "pages-preview", + "runtimeExecutable": "npm", + "runtimeArgs": [ + "run", + "preview:pages" + ], + "port": 4830, + "autoPort": true } ] } diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 702aa89..326464e 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,38 +1,65 @@ -name: Deploy website to GitHub Pages +name: GitHub Pages +# One site: the landing page (apps/website) at the root and the Expo web app (apps/mobile) +# under /app/. See docs/deployment.md. Pull requests typecheck and build it without deploying. on: push: branches: [main, develop] - paths: [apps/website/**, package-lock.json, .github/workflows/pages.yml] + paths: &site-paths + - apps/website/** + - apps/mobile/** + - packages/** + - package.json + - package-lock.json + - scripts/build-pages.mjs + - .github/workflows/pages.yml + pull_request: + paths: *site-paths workflow_dispatch: permissions: contents: read - pages: write - id-token: write concurrency: - group: pages + # One deployment at a time (the newest push wins); each pull request builds on its own. + group: ${{ github.event_name == 'pull_request' && format('pages-pr-{0}', github.event.number) || 'pages' }} cancel-in-progress: true jobs: - deploy: + build: runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: node-version: 24 cache: npm - - run: npm ci --workspace @hackyeah/website - - run: npm run typecheck --workspace @hackyeah/website - - run: npm run build --workspace @hackyeah/website - - uses: actions/configure-pages@v5 - - uses: actions/upload-pages-artifact@v3 + - run: npm ci + - run: npm run typecheck --workspace=@hackyeah/website + - run: npm run typecheck --workspace=@hackyeah/mobile + - run: npm run build:pages + env: + # Publishable values, not secrets: Expo bundles every EXPO_PUBLIC_ value into the + # client and row-level security guards the data. Repository variables of the same + # name override these defaults. + EXPO_PUBLIC_SUPABASE_URL: ${{ vars.EXPO_PUBLIC_SUPABASE_URL || 'https://xmzjxcxtsytwscktojzr.supabase.co' }} + EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY: ${{ vars.EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY || 'sb_publishable_E6t3h9-f_UyR_01fraY8YA_408Y_537' }} + - if: github.event_name != 'pull_request' + uses: actions/upload-pages-artifact@v5 with: - path: apps/website/dist + path: dist/pages + include-hidden-files: true # .nojekyll + + deploy: + if: github.event_name != 'pull_request' + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: - id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/AGENTS.md b/AGENTS.md index 53199d6..090ec64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,8 +4,9 @@ Build a beginner sport app for inactive adults: preferences, sport suggestions, AI-generated multi-day plans, completion, and feedback. Read the relevant docs below before changing a feature. -- Monorepo: `apps/backend` (NestJS), `apps/web` (React), `apps/mobile` (Expo), - `apps/website` (React marketing site and demo, deployed to GitHub Pages). +- Monorepo: `apps/backend` (NestJS), `apps/mobile` (Expo: iOS, Android and the web + app, deployed to GitHub Pages at `/app/`), `apps/website` (React landing page at the + GitHub Pages root). - Use TypeScript and shared lint/format rules. Current setup uses npm workspaces; pnpm is the agreed target. Follow the migration plan in `docs/development.md`. - Follow the [coding principles](docs/code-principles.md): one home per fact, one @@ -18,8 +19,12 @@ below before changing a feature. - Validate requests and AI output; enforce authenticated user ownership. - Validated chat revisions replace the active plan immediately. Preserve prior versions and completed activities. -- Guest entry (web only; mobile has none) creates an isolated anonymous identity - with seeded demo data. +- Guest entry (web only; the phone apps have none) signs in an anonymous Supabase + user named Guest on the real API. It starts at onboarding and can be saved as an + email account. +- The web app is the Expo web build. Desktop layouts (sidebar, docked coach, columns) + are gated by `useLayout()`; native and web below 768 px keep the phone layout. + See [Web app](docs/features/web-app.md). - Branch from `develop`; target feature PRs to `develop` and release PRs to `main`. Include acceptance criteria, verification evidence, and one teammate approval. - Keep secrets out of clients and Git. Keep mobile compatible with API changes. @@ -45,7 +50,9 @@ below before changing a feature. - [Mobile review decisions, 3 October](docs/mobile-review-2026-10-03.md) — newer where they differ - [Design reference: screens, tokens, preview](design/README.md) — read before building UI - [Mobile app on Supabase](docs/features/mobile-supabase-integration.md) — the default backend: Supabase Auth, the remote adapter, configuration and what is deferred +- [Web app and guest mode](docs/features/web-app.md) — the Expo web build as a responsive panel, guest entry, GitHub Pages deployment - [Mobile app on a mocked API](docs/features/mobile-mock-app.md) — the opt-in offline mock (`EXPO_PUBLIC_API_MODE=mock`), the demo account and Demo controls -An Expo starter and wearable contracts/extraction packages exist. Backend, dashboard and CI are planned; -do not claim planned commands or checks are already available. +The Expo app (phones and web), the Supabase product API and wearable packages exist. CI is +the GitHub Pages workflow (build on pull requests, deploy from `main`/`develop`); the NestJS +backend is planned. Do not claim planned commands or checks are already available. diff --git a/README.md b/README.md index d0038f0..1bc0698 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,17 @@ > Built during **HackYeah 2026**. -Movo is a **mobile app** currently in active development. The mobile app is not available for download yet, but a **web app preview** is available and working. - -In the future, Movo will also be available as a full **web app**, with a look similar to the current preview. +Movo is a sport app for beginners, built as one Expo app for phones and the web. The mobile app is not available for download yet; the **web app** runs in the browser. --- ## Project status -| Component | Status | -| ---------------- | ---------------------------- | -| Web app preview | Done | -| Mobile app | Not available yet | -| Web app | Not available yet | +| Component | Status | +| ------------ | ------------------------------------------------------- | +| Landing page | Live at https://taqix.github.io/hackyeah-2026/ | +| Web app | Live at https://taqix.github.io/hackyeah-2026/app/ | +| Mobile app | Not available yet | The project idea, technology stack, and full setup instructions will be expanded as development progresses. @@ -22,9 +20,11 @@ The project idea, technology stack, and full setup instructions will be expanded ## Requirements -To access the web app preview, all you need is the preview link: +To use Movo, open the web app in a browser, then sign in or continue as a guest: + +> https://taqix.github.io/hackyeah-2026/app/ -> https://taqix.github.io/hackyeah-2026/ +How the site is built and deployed: [GitHub Pages](docs/deployment.md#github-pages-landing-page-and-web-app). --- diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts new file mode 100644 index 0000000..8753f33 --- /dev/null +++ b/apps/mobile/app.config.ts @@ -0,0 +1,20 @@ +import type { ConfigContext, ExpoConfig } from 'expo/config'; + +/** + * app.json, plus a sub-path web export when EXPO_WEB_BASE_URL is set (the GitHub + * Pages build sets /hackyeah-2026/app). That export is a single-page app: a static + * host can't serve the static renderer's per-route pages for dynamic routes such as + * /session/, so every route loads one index.html and the router reads the URL. + * `expo start` and native builds leave it unset and get app.json unchanged. + */ +export default ({ config }: ConfigContext): ExpoConfig => { + const expo = config as ExpoConfig; + const path = process.env.EXPO_WEB_BASE_URL?.trim().replace(/^\/+|\/+$/g, ''); + if (!path) return expo; + + return { + ...expo, + web: { ...expo.web, output: 'single' }, + experiments: { ...expo.experiments, baseUrl: `/${path}` }, + }; +}; diff --git a/apps/mobile/src/api/client.ts b/apps/mobile/src/api/client.ts index 59fd16d..849eacc 100644 --- a/apps/mobile/src/api/client.ts +++ b/apps/mobile/src/api/client.ts @@ -42,6 +42,23 @@ export interface ApiClient { */ signUpWithEmail(email: string, password: string, name: string): Promise; signInWithGoogle(): Promise; + /** + * Continue as guest (web only; the mobile app has no guest entry): signs + * in a new anonymous account named "Guest" with no history, which uses the + * API like any account and goes through onboarding. Resolves with the + * session already signed in, if there is one, instead of making another. + * Rejects with GuestModeUnavailable (`remote/auth`) when the server has + * guests switched off. + */ + signInAsGuest(): Promise; + /** + * Save your progress: adds an email and password to the signed-in guest, + * which keeps its plan and history and becomes an email account. Rejects + * with confirmation_required when the email has to be confirmed first (the + * account stays a guest until then), email_taken, weak_password under 8 + * characters, validation for a bad email or when the account isn't a guest. + */ + upgradeGuest(email: string, password: string): Promise; sendPasswordReset(email: string): Promise; /** * Sets a new password for the signed-in account (the password reset link diff --git a/apps/mobile/src/api/hooks/auth.ts b/apps/mobile/src/api/hooks/auth.ts index 66e253e..bc7f2d0 100644 --- a/apps/mobile/src/api/hooks/auth.ts +++ b/apps/mobile/src/api/hooks/auth.ts @@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tansta import { api } from '@/api'; import { queryKeys } from '@/api/query-keys'; import { isSignInCancelled } from '@/api/remote/auth'; -import type { AuthSession } from '@/api/types'; +import type { Account, AuthSession } from '@/api/types'; /** Everything cached except the session and the providers belongs to one user. */ const isUserData = (key: readonly unknown[]) => key[0] !== 'auth'; @@ -77,6 +77,41 @@ export function useSignInWithGoogle() { }); } +/** + * Continue as guest (web only): signs in a new anonymous account named Guest, + * which the gate then sends to onboarding. Rejects with GuestModeUnavailable + * when the server has guests switched off. + */ +export function useSignInAsGuest() { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ['auth', 'sign-in-guest'], + mutationFn: () => api.auth.signInAsGuest(), + onSuccess: (session) => signedIn(queryClient, session), + }); +} + +/** + * Save your progress: adds an email and password to the signed-in guest. The + * person, plan and history stay the same, so only the session and the account + * are read again. Rejects with confirmation_required (check the inbox), + * email_taken, weak_password or validation. + */ +export function useUpgradeGuest() { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ['auth', 'upgrade-guest'], + mutationFn: ({ email, password }: { email: string; password: string }) => api.auth.upgradeGuest(email, password), + onSuccess: (session) => { + queryClient.setQueryData(queryKeys.session, session); + queryClient.setQueryData(queryKeys.account, (account) => + account ? { ...account, user: session.user } : account, + ); + void queryClient.invalidateQueries({ queryKey: queryKeys.account }); + }, + }); +} + /** Forgot password? Sends the reset email. */ export function useSendPasswordReset() { return useMutation({ diff --git a/apps/mobile/src/api/hooks/index.ts b/apps/mobile/src/api/hooks/index.ts index eff3f89..2d15605 100644 --- a/apps/mobile/src/api/hooks/index.ts +++ b/apps/mobile/src/api/hooks/index.ts @@ -10,11 +10,13 @@ export { useLookupEmail, useSendPasswordReset, useSession, + useSignInAsGuest, useSignInWithEmail, useSignInWithGoogle, useSignOut, useSignUpWithEmail, useUpdatePassword, + useUpgradeGuest, } from './auth'; export { useSports, useSport } from './catalog'; export { useChatMessages, useSendChatMessage, useUndoChatMessage, type SendChatMessageInput } from './chat'; diff --git a/apps/mobile/src/api/mock/backend.ts b/apps/mobile/src/api/mock/backend.ts index 72e73ba..7c9e740 100644 --- a/apps/mobile/src/api/mock/backend.ts +++ b/apps/mobile/src/api/mock/backend.ts @@ -21,6 +21,7 @@ import { answersSentence } from '../../lib/preference-options'; import { sessionLocalDate, sessionMinutes, sessionStart, sortSessions } from '../../lib/sessions'; import { ApiError, + GUEST_NAME, type Account, type ActivityLog, type AssistantSummary, @@ -65,6 +66,8 @@ export const BUILD_MS = 3_000; export const GOOGLE_EMAIL = 'sam@gmail.example'; /** The one password that is always wrong, to show 1.3. */ export const WRONG_PASSWORD = 'wrong-password'; +/** Saving a guest with an email at this domain answers as if the email had to be confirmed first. */ +export const CONFIRM_FIRST_DOMAIN = 'confirm.example'; const FAILURE_MESSAGE = 'Something went wrong on our side. Your answers are saved, so trying again only takes a moment.'; const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -420,6 +423,33 @@ export function createBackend(deps: BackendDeps) { db().session_user_id = acc.user.id; return sessionFor(acc.user); }, + /** A fresh account with no email, password or history; a session already signed in is kept. */ + signInAsGuest(): AuthSession { + const d = db(); + const current = d.accounts.find((a) => a.user.id === d.session_user_id); + if (current) return sessionFor(current.user); + const acc = createAccount('', null, GUEST_NAME, 'guest'); + d.session_user_id = acc.user.id; + return sessionFor(acc.user); + }, + upgradeGuest(email: string, password: string): AuthSession { + const acc = account(); + if (acc.user.provider !== 'guest') { + throw new ApiError('validation', 'This account already has an email and password.'); + } + const e = validateEmail(email); + if (password.length < 8) throw new ApiError('weak_password', 'Use at least 8 characters.'); + if (db().accounts.some((a) => a.user.email === e)) { + throw new ApiError('email_taken', 'An account already uses this email.'); + } + if (e.endsWith(`@${CONFIRM_FIRST_DOMAIN}`)) { + throw new ApiError('confirmation_required', `Open the link we sent to ${e} to finish saving your account.`); + } + acc.user.email = e; + acc.user.provider = 'email'; + acc.password = password; + return sessionFor(acc.user); + }, sendPasswordReset(email: string): void { validateEmail(email); }, diff --git a/apps/mobile/src/api/mock/client.ts b/apps/mobile/src/api/mock/client.ts index d2c3a37..372a9f4 100644 --- a/apps/mobile/src/api/mock/client.ts +++ b/apps/mobile/src/api/mock/client.ts @@ -82,6 +82,8 @@ export function createMockApiClient(): ApiClient { signInWithEmail: (email, password) => call(() => backend.auth.signInWithEmail(email, password)), signUpWithEmail: (email, password, name) => call(() => backend.auth.signUpWithEmail(email, password, name)), signInWithGoogle: () => call(() => backend.auth.signInWithGoogle()), + signInAsGuest: () => call(() => backend.auth.signInAsGuest()), + upgradeGuest: (email, password) => call(() => backend.auth.upgradeGuest(email, password)), sendPasswordReset: (email) => call(() => backend.auth.sendPasswordReset(email)), updatePassword: (password) => call(() => backend.auth.updatePassword(password)), getProviders: () => call(() => backend.auth.getProviders()), diff --git a/apps/mobile/src/api/mock/model.ts b/apps/mobile/src/api/mock/model.ts index 371f669..67253e6 100644 --- a/apps/mobile/src/api/mock/model.ts +++ b/apps/mobile/src/api/mock/model.ts @@ -20,7 +20,7 @@ export const DB_SCHEMA = 1; export interface MockAccount { user: User; - /** Null for Google accounts. */ + /** Null for Google accounts and guests. */ password: string | null; } diff --git a/apps/mobile/src/api/remote/auth.ts b/apps/mobile/src/api/remote/auth.ts index e5e7065..62d8b14 100644 --- a/apps/mobile/src/api/remote/auth.ts +++ b/apps/mobile/src/api/remote/auth.ts @@ -10,7 +10,7 @@ import { } from '../../lib/debug-log'; import type { ApiClient } from '../client'; import { checkName } from '../../lib/person-name'; -import { ApiError, isApiError, type AuthProviders, type AuthSession } from '../types'; +import { ApiError, GUEST_NAME, isApiError, isGuest, type AuthProviders, type AuthSession } from '../types'; import type { RemoteContext } from './context'; import type { AuthErrorLike, AuthPort, AuthSessionData, RemoteDeps } from './deps'; import { checkGoogleRedirect, EXPO_GO_IP_SIGN_IN } from './expo-go-redirect'; @@ -30,6 +30,9 @@ const LINK_EXPIRED = 'This link has expired or was opened on another device. Ask const TOO_MANY_TRIES = 'Too many tries for now. Wait a minute, then try again.'; const NOT_FINISHED = "Sign-in didn't finish. Try again."; const GOOGLE_NOT_FINISHED = "Google sign-in didn't finish. Try again."; +const GUEST_OFF = "Guest mode isn't available right now. Sign in with email instead."; +const ALREADY_SAVED = 'This account already has an email and password.'; +const confirmToSave = (email: string) => `Open the link we sent to ${email} to finish saving your account.`; /** Asks Google to show its account chooser, so a phone with several Google accounts can pick one. */ export const GOOGLE_SIGN_IN_PARAMS = { prompt: 'select_account' } as const; @@ -47,14 +50,29 @@ export class SignInCancelled extends ApiError { export const isSignInCancelled = (error: unknown): error is SignInCancelled => error instanceof SignInCancelled; +/** + * The project has anonymous sign-ins (web guests) switched off. Not + * retryable: signing in with email or Google is the way on. + */ +export class GuestModeUnavailable extends ApiError { + constructor(cause?: unknown) { + super('unknown', GUEST_OFF, { retryable: false, cause }); + this.name = 'GuestModeUnavailable'; + } +} + +export const isGuestModeUnavailable = (error: unknown): error is GuestModeUnavailable => + error instanceof GuestModeUnavailable; + /* -------------------------------------------------------------- Mapping */ const text = (value: unknown): string | null => (typeof value === 'string' && value.trim() ? value.trim() : null); /** * A Supabase session as the app's AuthSession. The name comes from the user - * metadata: Google's, or the one given at sign-up or in Settings (both write - * `full_name` and `name`). + * metadata: Google's, or the one given at sign-up, to a guest, or in Settings + * (all write `full_name` and `name`). A guest is told apart by `is_anonymous` + * alone: its app_metadata has no provider and its email is empty. */ export function toAuthSession(session: AuthSessionData): AuthSession { const { user } = session; @@ -63,7 +81,7 @@ export function toAuthSession(session: AuthSessionData): AuthSession { id: user.id, email: user.email ?? '', name: text(user.user_metadata.full_name) ?? text(user.user_metadata.name), - provider: user.app_metadata.provider === 'google' ? 'google' : 'email', + provider: user.is_anonymous === true ? 'guest' : user.app_metadata.provider === 'google' ? 'google' : 'email', created_at: user.created_at, }, access_token: session.access_token, @@ -156,6 +174,9 @@ export function mapAuthError(error: unknown): ApiError { if (code === 'signup_disabled' || code === 'email_provider_disabled') { return make('unknown', "New accounts can't be created right now."); } + if (code === 'anonymous_provider_disabled' || (!code && /anonymous sign-ins are disabled/i.test(message))) { + return new GuestModeUnavailable(error); + } return make('unknown', defaultErrorMessage('unknown'), status !== undefined && status >= 500); } @@ -323,6 +344,9 @@ function withAuthLogging(client: ApiClient['auth']): ApiClient['auth'] { signUpWithEmail: (email, password, name) => traced('sign-up (email)', email, () => client.signUpWithEmail(email, password, name), sessionTag), signInWithGoogle: () => traced('sign-in (google)', null, () => client.signInWithGoogle(), sessionTag), + signInAsGuest: () => traced('sign-in (guest)', null, () => client.signInAsGuest(), sessionTag), + upgradeGuest: (email, password) => + traced('save guest (email)', email, () => client.upgradeGuest(email, password), sessionTag), sendPasswordReset: (email) => traced('password reset email', email, () => client.sendPasswordReset(email)), updatePassword: (password) => traced('new password', null, () => client.updatePassword(password)), getProviders: () => @@ -345,10 +369,11 @@ function checkPassword(password: string) { /** * Supabase Auth (`ctx.deps.auth`): email and password, Google through PKCE - * (`deps.openAuthSession`, `deps.redirectUrl('auth/callback')`), password - * reset and the new password, providers from GET {supabaseUrl}/auth/v1/settings - * (`deps.fetch` with the publishable key), and sign-out, which also - * `ctx.data.reset()`s. + * (`deps.openAuthSession`, `deps.redirectUrl('auth/callback')`), web guests + * (an anonymous sign-in, saved later by adding an email and password to the + * same user), password reset and the new password, providers from GET + * {supabaseUrl}/auth/v1/settings (`deps.fetch` with the publishable key), and + * sign-out, which also `ctx.data.reset()`s. */ export function createRemoteAuth(ctx: RemoteContext): ApiClient['auth'] { const { deps } = ctx; @@ -402,8 +427,9 @@ export function createRemoteAuth(ctx: RemoteContext): ApiClient['auth'] { }, async signInWithGoogle() { - // Linking.createURL: hackyeah2026://auth/callback in a build, exp://…/--/auth/callback - // in Expo Go, http://localhost:8081/auth/callback on the web; all are allowlisted. + // hackyeah2026://auth/callback in a build, exp://…/--/auth/callback in Expo Go, and on + // the web the page's origin under the app's base URL (…/hackyeah-2026/app/auth/callback + // on GitHub Pages, http://localhost:8081/auth/callback locally); all are allowlisted. const redirectTo = deps.redirectUrl(CALLBACK_PATH); // Expo Go on a LAN IP: Supabase would send Google's answer to the Site URL instead. checkGoogleRedirect(redirectTo, EXPO_GO_IP_SIGN_IN); @@ -438,6 +464,43 @@ export function createRemoteAuth(ctx: RemoteContext): ApiClient['auth'] { } }, + async signInAsGuest() { + // One guest per browser: a session already here (a double tap, a reload) is kept, not left behind. + const existing = await readSession(auth); + if (existing) return withProfileName(ctx, existing); + // An anonymous user gets a normal JWT, so the product API and RLS treat it like any account. + // The signup trigger copies the metadata name into the profile's username. + const { data, error } = await call(() => + auth.signInAnonymously({ options: { data: { name: GUEST_NAME, full_name: GUEST_NAME } } }), + ); + if (error) throw mapAuthError(error); + if (!data.session) throw new ApiError('unknown', NOT_FINISHED); + return toAuthSession(data.session); + }, + + async upgradeGuest(email, password) { + const normalized = validEmail(email); + checkPassword(password); + const current = await call(() => auth.getSession()); + if (isNetworkError(current.error)) throw mapAuthError(current.error); + const stored = current.data.session; + if (!stored) throw new ApiError('unauthorized', defaultErrorMessage('unauthorized')); + if (!isGuest(toAuthSession(stored).user)) throw new ApiError('validation', ALREADY_SAVED); + // The same user keeps its id, so its plan, history and chat stay. Supabase + // refuses a password on an anonymous user without an email, so both go together. + const { data, error } = await call(() => + auth.updateUser({ email: normalized, password }, { emailRedirectTo: deps.redirectUrl(CALLBACK_PATH) }), + ); + if (error) throw mapAuthError(error); + // With email confirmation on, the email waits in new_email and the account + // stays a guest until the link is opened (it returns to /auth/callback). + const user = data.user; + if (!user || user.is_anonymous === true || !user.email) { + throw new ApiError('confirmation_required', confirmToSave(normalized)); + } + return withProfileName(ctx, toAuthSession({ ...stored, user })); + }, + async sendPasswordReset(email) { const normalized = validEmail(email); const { error } = await call(() => diff --git a/apps/mobile/src/api/remote/default.ts b/apps/mobile/src/api/remote/default.ts index d8baaeb..e013df6 100644 --- a/apps/mobile/src/api/remote/default.ts +++ b/apps/mobile/src/api/remote/default.ts @@ -19,6 +19,7 @@ import { apiConfig } from '../config'; import { getSupabase } from '../supabase'; import { createRemoteRuntime, type RemoteRuntime } from './client'; import type { AuthPort, KeyValueStorage, RemoteDeps } from './deps'; +import { webAppUrl } from './redirect-url'; /** * supabase.auth behind the AuthPort, resolved on every call so the client is @@ -29,13 +30,14 @@ export const supabaseAuth: AuthPort = { getSession: () => getSupabase().auth.getSession(), signInWithPassword: (credentials) => getSupabase().auth.signInWithPassword(credentials), signUp: (credentials) => getSupabase().auth.signUp(credentials), + signInAnonymously: (credentials) => getSupabase().auth.signInAnonymously(credentials), signInWithOAuth: (credentials) => getSupabase().auth.signInWithOAuth(credentials), linkIdentity: (credentials) => getSupabase().auth.linkIdentity(credentials), getUserIdentities: () => getSupabase().auth.getUserIdentities(), setSession: (tokens) => getSupabase().auth.setSession(tokens), exchangeCodeForSession: (authCode) => getSupabase().auth.exchangeCodeForSession(authCode), resetPasswordForEmail: (email, options) => getSupabase().auth.resetPasswordForEmail(email, options), - updateUser: (attributes) => getSupabase().auth.updateUser(attributes), + updateUser: (attributes, options) => getSupabase().auth.updateUser(attributes, options), signOut: (options) => getSupabase().auth.signOut(options), refreshSession: () => getSupabase().auth.refreshSession(), onAuthStateChange: (callback) => getSupabase().auth.onAuthStateChange(callback), @@ -57,6 +59,18 @@ const secureStorage: KeyValueStorage = removeItem: (key) => SecureStore.deleteItemAsync(key), }; +/** + * Where Auth links return (Google, the confirmation and reset emails): an app + * deep link, or on the web this page's address under the router's base URL + * (`experiments.baseUrl`). Expo Router applies the base URL outside + * development only, because the dev server serves the app at the root. + */ +function redirectUrl(path: string): string { + if (Platform.OS !== 'web' || typeof window === 'undefined') return Linking.createURL(path); + const baseUrl = process.env.NODE_ENV === 'development' ? '' : (process.env.EXPO_BASE_URL ?? ''); + return webAppUrl(window.location.origin, path, baseUrl); +} + /** Google Calendar's free/busy for planning when connected; any problem reading the setting means "not now". */ async function googleFreeTime() { try { @@ -83,7 +97,7 @@ function defaultDeps(): RemoteDeps { if (result.type === 'success') return { type: 'success', url: result.url }; return { type: result.type === 'cancel' ? 'cancel' : 'dismiss' }; }, - redirectUrl: (path) => Linking.createURL(path), + redirectUrl, secureStorage, googleFetch: (url, init) => fetch(url, init), }; diff --git a/apps/mobile/src/api/remote/deps.ts b/apps/mobile/src/api/remote/deps.ts index 70e4806..8c72fc1 100644 --- a/apps/mobile/src/api/remote/deps.ts +++ b/apps/mobile/src/api/remote/deps.ts @@ -39,8 +39,13 @@ export interface AuthErrorLike { export interface AuthUser { id: string; + /** Empty (or missing) for an anonymous user. */ email?: string; + /** An email waiting for confirmation (a guest adding one with confirmation on). */ + new_email?: string; created_at: string; + /** A guest: signed in anonymously, with no email or password yet. Its app_metadata has no provider. */ + is_anonymous?: boolean; /** `provider` is `email` or `google`. */ app_metadata: { provider?: string; [key: string]: unknown }; /** Google fills `full_name`/`name`. */ @@ -113,6 +118,12 @@ export interface AuthPort { provider: 'google'; options?: OAuthOptions; }): Promise<{ data: { url: string | null }; error: AuthErrorLike | null }>; + /** + * A new anonymous user (web guests), with `data` as its user metadata. + * Fails with `anonymous_provider_disabled` while the project has anonymous + * sign-ins switched off. + */ + signInAnonymously(credentials?: { options?: { data?: object } }): Promise; /** * Links Google to the signed-in user (PKCE). Needs manual linking switched * on in Supabase Auth, else `manual_linking_disabled`. @@ -127,7 +138,15 @@ export interface AuthPort { setSession(tokens: { access_token: string; refresh_token: string }): Promise; exchangeCodeForSession(authCode: string): Promise; resetPasswordForEmail(email: string, options?: { redirectTo?: string }): Promise<{ error: AuthErrorLike | null }>; - updateUser(attributes: { password?: string; data?: object }): Promise<{ + /** + * Changes the signed-in user. An email (with a password) on an anonymous + * user makes it permanent at once with confirmation off; with it on, the + * email waits in `new_email` until its link (to `emailRedirectTo`) is opened. + */ + updateUser( + attributes: { email?: string; password?: string; data?: object }, + options?: { emailRedirectTo?: string }, + ): Promise<{ data: { user: AuthUser | null }; error: AuthErrorLike | null; }>; diff --git a/apps/mobile/src/api/remote/redirect-url.ts b/apps/mobile/src/api/remote/redirect-url.ts new file mode 100644 index 0000000..0b8403b --- /dev/null +++ b/apps/mobile/src/api/remote/redirect-url.ts @@ -0,0 +1,11 @@ +/** + * The web address of an app route: the page's origin, then the router's base + * URL when the app is served under one (GitHub Pages serves it at + * `/hackyeah-2026/app`), then the route. expo-linking's `createURL` drops the + * base URL on the web, so Auth links built with it (Google's return, the + * confirmation and reset emails) would land outside the app. + */ +export function webAppUrl(origin: string, path: string, baseUrl = ''): string { + const parts = [origin.replace(/\/+$/, ''), baseUrl.trim().replace(/^\/+|\/+$/g, ''), path.replace(/^\/+/, '')]; + return parts.filter(Boolean).join('/'); +} diff --git a/apps/mobile/src/api/types.ts b/apps/mobile/src/api/types.ts index df459f7..d7197cf 100644 --- a/apps/mobile/src/api/types.ts +++ b/apps/mobile/src/api/types.ts @@ -24,10 +24,16 @@ export type LocalTime = string; /* ------------------------------------------------------------------ Auth */ -export type AuthProvider = 'google' | 'email'; +/** + * How the account signs in. `guest`: a web guest (Continue as guest), an + * anonymous account with no email or password that lives in one browser + * until it is saved with an email (`auth.upgradeGuest`). + */ +export type AuthProvider = 'google' | 'email' | 'guest'; export interface User { id: string; + /** Empty for a guest, which has no email. */ email: string; /** * The name the person is greeted by: asked at sign-up, from Google, or set in @@ -44,6 +50,12 @@ export interface AuthSession { access_token: string; } +/** A web guest's account: anonymous, kept in this browser until saved with an email. */ +export const isGuest = (user: Pick | null | undefined): boolean => user?.provider === 'guest'; + +/** The name a new guest is greeted by (Settings › Account › Name changes it). */ +export const GUEST_NAME = 'Guest'; + /** Which sign-in methods the server has switched on (Welcome hides Google while it is off). */ export interface AuthProviders { google: boolean; diff --git a/apps/mobile/src/api/unconfigured.ts b/apps/mobile/src/api/unconfigured.ts index 5d0a42e..a899a71 100644 --- a/apps/mobile/src/api/unconfigured.ts +++ b/apps/mobile/src/api/unconfigured.ts @@ -18,6 +18,8 @@ export function createUnconfiguredApiClient(missing: string[]): ApiClient { signInWithEmail: fail, signUpWithEmail: fail, signInWithGoogle: fail, + signInAsGuest: fail, + upgradeGuest: fail, sendPasswordReset: fail, updatePassword: fail, getProviders: fail, diff --git a/apps/mobile/src/app/(tabs)/_layout.tsx b/apps/mobile/src/app/(tabs)/_layout.tsx index cdefc43..83f8795 100644 --- a/apps/mobile/src/app/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/_layout.tsx @@ -6,6 +6,7 @@ import { useStepCounterTracking } from '@/hooks/use-step-counter'; import { FloatingTabBar } from '@/navigation/floating-tab-bar'; import { GateError, GateLoading } from '@/navigation/gate-states'; import { useAccountState } from '@/navigation/use-account-state'; +import { useShellVisible } from '@/navigation/web/shell-visibility'; import { useTheme } from '@/theme'; /** Tabs need a session and saved answers. */ @@ -29,13 +30,15 @@ function TabsNavigator() { // Steps are read only once the person reaches the app, not during sign-in or onboarding. useStepCounterTracking(); const { colors } = useTheme(); + // The desktop web's sidebar takes the floating tab bar's place. + const sidebar = useShellVisible(); return ( <> {/* Copies planned sessions to the Movo calendar once the person turns that on. */} } + tabBar={(props) => (sidebar ? null : )} screenOptions={{ headerShown: false, sceneStyle: { backgroundColor: colors.bgApp } }}> diff --git a/apps/mobile/src/app/+html.tsx b/apps/mobile/src/app/+html.tsx new file mode 100644 index 0000000..f8110ce --- /dev/null +++ b/apps/mobile/src/app/+html.tsx @@ -0,0 +1,40 @@ +import { ScrollViewStyleReset, useServerDocumentContext } from 'expo-router/html'; +import type { PropsWithChildren } from 'react'; + +import { FAVICON, GLOBAL_CSS } from '@/navigation/web/document-styles'; +import { colors } from '@/theme/tokens'; + +/* + * The web's HTML document (static rendering and the dev server; never on iOS + * or Android). It paints the app's paper background before any script runs, + * in the system's light or dark, so a page never flashes white; once the app + * runs, DocumentHead (navigation/web) keeps these values on the theme people + * chose in Settings › Appearance. + */ + +const { light, dark } = colors; + +export default function Root({ children }: PropsWithChildren) { + const { htmlAttributes, bodyAttributes, headNodes, bodyNodes } = useServerDocumentContext(); + return ( + + + + + + + + + + + + ` + ); +} + +/** For 404.html: paths under the app are its routes (it reads the URL); any other unknown path goes to the landing page. */ +function landingRedirect() { + return ( + `` + ); +} + +function assemble() { + rmSync(out, { recursive: true, force: true }); + cpSync(websiteDist, out, { recursive: true }); + for (const path of ['app', '404.html']) { + if (existsSync(join(out, path))) throw new Error(`apps/website/dist has its own ${path}, which the app needs`); + } + cpSync(appDist, join(out, 'app'), { recursive: true }); + + const appHtmlPath = join(out, 'app/index.html'); + const appHtml = readFileSync(appHtmlPath, 'utf8').replace('', `${launchHead()}`); + if (!appHtml.includes(`src="${appBase}/_expo/static/js/web/`)) { + throw new Error(`app/index.html doesn't load its bundle from ${appBase}/_expo/; is experiments.baseUrl applied?`); + } + writeFileSync(appHtmlPath, appHtml); + writeFileSync(join(out, '404.html'), appHtml.replace('', `${landingRedirect()}`)); + writeFileSync(join(out, '.nojekyll'), ''); +} + +function summary() { + const files = readdirSync(join(out, 'app'), { recursive: true, withFileTypes: true }).filter((entry) => entry.isFile()); + const bytes = files.reduce((total, entry) => total + statSync(join(entry.parentPath, entry.name)).size, 0); + const rows = [ + [`${base}/`, 'landing page'], + [`${appBase}/`, `web app (${files.length} files, ${(bytes / 1e6).toFixed(1)} MB)`], + ['404.html', 'the app again, for deep links; other unknown paths go to the landing page'], + ]; + const width = Math.max(...rows.map(([path]) => path.length)) + 2; + console.log('\nPages site ready in dist/pages:'); + for (const [path, what] of rows) console.log(` ${path.padEnd(width)}${what}`); +} + +try { + checkBackendEnv(); + run('npm', ['run', 'build', '--workspace=@hackyeah/website'], { cwd: root }); + exportApp(); + assemble(); + summary(); +} catch (error) { + console.error(`\nbuild:pages failed: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; +} diff --git a/scripts/preview-pages.mjs b/scripts/preview-pages.mjs new file mode 100644 index 0000000..4661ad3 --- /dev/null +++ b/scripts/preview-pages.mjs @@ -0,0 +1,64 @@ +/** + * Serves dist/pages the way GitHub Pages serves the project site, so deep links + * and reloads can be checked before a deploy: under PREFIX (default + * /hackyeah-2026), a path is the file, else .html, else /index.html + * (a directory without its trailing slash gets a 301), else 404.html with + * status 404. Usage: node scripts/preview-pages.mjs [dir] [port] [prefix] + * (PORT in the environment wins over the argument). + */ +import { createReadStream, existsSync, statSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { extname, join, normalize } from 'node:path'; + +const [dir = 'dist/pages', portArg = '4830', prefix = '/hackyeah-2026'] = process.argv.slice(2); +const port = Number(process.env.PORT ?? portArg); +const types = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json', + '.png': 'image/png', + '.ico': 'image/x-icon', + '.svg': 'image/svg+xml', + '.ttf': 'font/ttf', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.jpg': 'image/jpeg', + '.webp': 'image/webp', +}; +const isFile = (path) => existsSync(path) && statSync(path).isFile(); +const isDir = (path) => existsSync(path) && statSync(path).isDirectory(); + +function send(res, status, file) { + res.writeHead(status, { 'Content-Type': types[extname(file)] ?? 'application/octet-stream', 'Cache-Control': 'no-store' }); + createReadStream(file).pipe(res); +} + +if (!isFile(join(dir, '404.html'))) { + console.error(`No site in ${dir}. Run npm run build:pages first.`); + process.exit(1); +} + +createServer((req, res) => { + const { pathname } = new URL(req.url, 'http://local'); + if (pathname === '/') { + res.writeHead(302, { Location: `${prefix}/` }).end(); + return; + } + if (pathname !== prefix && !pathname.startsWith(`${prefix}/`)) { + res.writeHead(404).end('Not under the site prefix'); + return; + } + const rel = normalize(decodeURIComponent(pathname.slice(prefix.length))).replace(/^(\.\.[/\\])+/, ''); + const path = join(dir, rel); + if (isFile(path)) return send(res, 200, path); + if (isDir(path)) { + if (!pathname.endsWith('/')) { + res.writeHead(301, { Location: `${pathname}/` }).end(); + return; + } + if (isFile(join(path, 'index.html'))) return send(res, 200, join(path, 'index.html')); + } + if (isFile(`${path}.html`)) return send(res, 200, `${path}.html`); + send(res, 404, join(dir, '404.html')); +}).listen(port, () => console.log(`Serving ${dir} at http://localhost:${port}${prefix}/`));