diff --git a/.clerk/.tmp/README.md b/.clerk/.tmp/README.md new file mode 100755 index 00000000..43c1d368 --- /dev/null +++ b/.clerk/.tmp/README.md @@ -0,0 +1,4 @@ + +## DO NOT COMMIT +This directory is auto-generated from `@clerk/nextjs` because you are running in Keyless mode. Avoid committing the `.clerk/` directory as it includes the secret key of the unclaimed instance. + \ No newline at end of file diff --git a/.clerk/.tmp/keyless.json b/.clerk/.tmp/keyless.json new file mode 100755 index 00000000..78ad4454 --- /dev/null +++ b/.clerk/.tmp/keyless.json @@ -0,0 +1 @@ +{"publishableKey":"pk_test_bGlrZWQtamF2ZWxpbi02LmNsZXJrLmFjY291bnRzLmRldiQ","secretKey":"sk_test_e8dSaISCl0dIAbCRiHba1VhIrA1w8dfC9Bs2oZ5dFF","claimUrl":"https://dashboard.clerk.com/apps/claim?token=8a9lyxhq1f7tw70fxjabtor0finukxcsilfzefrp","apiKeysUrl":"https://dashboard.clerk.com/apps/app_37RA7v85L1UhcPG9G5373cbssXO/instances/ins_37RA7sD9nohiRs5A4hY0B9S0wxx/api-keys"} \ No newline at end of file diff --git a/.clerk/.tmp/telemetry.json b/.clerk/.tmp/telemetry.json new file mode 100644 index 00000000..7b8ecb0f --- /dev/null +++ b/.clerk/.tmp/telemetry.json @@ -0,0 +1,4 @@ +{ + "firedAt": "2025-12-27T17:10:41.974Z", + "event": "KEYLESS_ENV_DRIFT_DETECTED" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9cfae31e..0d9d8aef 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,19 @@ tsconfig.tsbuildinfo # vercel .vercel + +# clerk (auto-generated, contains secret keys) +.clerk + +# local tooling (do not commit) +.tools + +# local npm cache (do not commit) +.npm-cache/ + +# gradle caches (do not commit) +.gradle/ + +# android signing (do not commit) +*.jks +android/keystore.properties \ No newline at end of file diff --git a/README.md b/README.md index ef9258ea..003a5f54 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,160 @@ -# Next.js + Tailwind CSS + Ionic Framework + Capacitor Mobile Starter +# FlowBalance — Next.js + Ionic + Capacitor + Clerk -![Screenshot](./screenshot.png) +This repo is a hybrid mobile app (Android/iOS) + web app: +- **UI**: Next.js (App Router) + Ionic React + Tailwind +- **Auth**: Clerk (`@clerk/nextjs`) with **email verification code** (passwordless) +- **Billing**: Clerk Billing (B2C) shown in **external browser** (Google Play compliance) +- **Mobile shell**: Capacitor (WebView pointing at the deployed Next.js app) -This repo is a conceptual starting point for building an iOS, Android, and Progressive Web App with Next.js, Tailwind CSS, [Ionic Framework](https://ionicframework.com/), and [Capacitor](https://capacitorjs.com/). +The project is intentionally built to avoid “spaghetti” workarounds: we prefer Clerk’s prebuilt components and documented configuration over custom auth UIs. -Next.js handles the production React app experience, Tailwind can be used to style each page of your app, Ionic Framework provides the cross-platform system controls (navigation/transitions/tabs/etc.), and then Capacitor bundles all of it up and runs it on iOS, Android, and Web with full native access. +--- -See this blog post for an overview of the stack and how it all works: +## Fast commands -## Usage - -This project is a standard Next.js app, so the typical Next.js development process applies (`npm run dev` for browser-based development). However, there is one caveat: the app must be exported to deploy to iOS and Android, since it must run purely client-side. ([more on Next.js export](https://nextjs.org/docs/advanced-features/static-html-export)) +```bash +npm run dev +``` -To build the app, run: +```bash +npm run make-android +``` ```bash -npm run build +npm run make-android-release ``` -All the client side files will be sent to the `./out/` directory. These files need to be copied to the native iOS and Android projects, and this is where Capacitor comes in: +### One-command “commit & deploy to Vercel” + +We added a helper script: ```bash -npm run sync +npm run vercel -- "your commit message" ``` -Finally, use the following run commands to run the app on each platform: +This runs: `git add .` → `git commit -m ...` → `git push origin main`. + +--- + +## Architecture (how routing works) + +This repo uses Ionic’s router for the in-app experience, and Next routes for auth + web-only pages. + +- **Main app shell**: `components/AppShell.tsx` + - Ionic routes: `/home`, `/flows`, `/progress`, `/settings` +- **Next routes**: + - `/sign-in` → `app/sign-in/[[...sign-in]]/page.tsx` (Clerk ``) + - `/sign-up` → `app/sign-up/[[...sign-up]]/page.tsx` (Clerk ``) + - `/subscribe-web` → `app/subscribe-web/page.tsx` (Clerk ``, web-only) + - `/post-signup-redirect` → `app/post-signup-redirect/page.tsx` (legacy redirect helper; keep unless you remove env/dashboard redirects) + +--- + +## Clerk: rules of engagement (important) + +### What we want +- **No password sign-up** (email verification code only) +- **No custom auth forms** unless Clerk prebuilt cannot support a requirement + +### What we actually use in code +- Prebuilt components: + - `@clerk/nextjs` `` and `` + - `fallbackRedirectUrl` + `forceRedirectUrl` props (new API; replaces `afterSignInUrl` / `afterSignUpUrl`) + +### Official docs to reference first +- Redirect URLs: `https://clerk.com/docs/guides/development/customize-redirect-urls` +- Billing (B2C): `https://clerk.com/docs/nextjs/guides/billing/for-b2c` + +--- + +## Mobile: Capacitor configuration (critical) + +### 1) Keep navigation inside the app WebView +When `server.url` points to a remote domain, that domain **must** be included in `server.allowNavigation` or Capacitor will open it externally. + +Config lives in: +- `capacitor.config.json` (source of truth) +- generated copies under `android/.../capacitor.config.json` and `ios/.../capacitor.config.json` (sync keeps them updated) + +### 2) Do NOT enable `CapacitorHttp` for Clerk +**Root cause of the “mobile WebView shows Email+Password (missing Clerk footer) vs browser shows correct passwordless sign-up”**: +when `CapacitorHttp` is enabled, Clerk’s requests go through native HTTP, which breaks the WebView cookie/session model Clerk expects. + +Keep this setting: +- `plugins.CapacitorHttp.enabled = false` + +If you change Capacitor config, always sync: ```bash -npm run ios -npm run android +npx cap sync android ``` -## Livereload/Instant Refresh +--- -To enable Livereload and Instant Refresh during development (when running `npm run dev`), find the IP address of your local interface (ex: `192.168.1.2`) and port your Next.js server is running on, and then set the server url config value to point to it in `capacitor.config.json`: +## Billing / subscriptions (Google Play compliance) -```json -{ - "server": { - "url": "http://192.168.1.2:3000" - } -} -``` +### Requirement +**Payments must happen outside the app WebView**. + +### Current approach +- In-app buttons open the **system browser** to: + - `https://flowbalance-jdk.vercel.app/subscribe-web` +- We pass a **Clerk sign-in token** so the browser page is authenticated: + - `app/api/create-sign-in-token/route.ts` + - The native app opens: `/subscribe-web?__clerk_ticket=...` + - The web page consumes the ticket using `useSignIn().signIn.create({ strategy: 'ticket', ticket })` + +Key files: +- `components/pages/Settings.tsx` (“Manage Subscription”) +- `components/PaywallModal.tsx` (premium upsell → external browser) +- `helpers/openExternal.ts` / `helpers/openSubscriptionPage.ts` +- `app/subscribe-web/page.tsx` -Note: this configuration wil be easier in Capacitor 3 which [recently went into beta](https://capacitorjs.com/blog/announcing-capacitor-3-0-beta). +--- -## API Routes +## Known gotchas (and how we avoid them) -API Routes can be used but some minimal configuration is required. See [this discussion](https://github.com/mlynch/nextjs-tailwind-ionic-capacitor-starter/issues/4#issuecomment-754030049) for more information. +### Next.js hydration errors +Don’t render `window.location`, `navigator.userAgent`, etc. directly into JSX that’s server-rendered. Capture them in `useEffect` instead. -## SEO & Static Hosting +### Ionic SSR warnings +Sometimes `` elements can warn about extra `class` attributes during hydration. Avoid putting Tailwind `className` directly on Ionic web components when it causes warnings; put styling on wrappers. -- **Is the exported PWA crawlable?** By default this starter renders everything on the client (see `components/AppShell.tsx`), so the static build contains a minimal HTML shell that immediately hands off to JavaScript. Search engine bots that depend on server-rendered HTML will not see meaningful page content, so the project is not SEO-friendly out of the box. If you need full SEO support, SSR/SSG needs to be working on the routes you want indexed or using a parallel, SEO-optimized web surface, but take a look at the below caveat about this project. -- **Can I host the export as static HTML?** Yes. `next.config.js` sets `output: 'export'`, so `npm run build` writes a fully static bundle to the `out/` directory. You can deploy those files to any static host (e.g. Vercel static, Netlify, S3, GitHub Pages) or let Capacitor copy them into the native shells. +--- -## Caveats +## Debugging utilities -One caveat with this project: Because the app must be able to run purely client-side and use [Next.js's Export command](https://nextjs.org/docs/advanced-features/static-html-export), that means no Server Side Rendering in this code base. There is likely a way to SSR and a fully static Next.js app in tandem but it requires [a Babel plugin](https://github.com/erzr/next-babel-conditional-ssg-ssr) or would involve a more elaborate monorepo setup with code sharing that is out of scope for this project. +We keep the debug UI **as an optional component**: +- `components/DebugInfoBox.tsx` -Additionally, Next.js routing is not really used much in this app beyond a catch-all route to render the native app shell and engage the Ionic React Router. This is primarily because Next.js routing is not set up to enable native-style transitions and history state management like the kind Ionic uses. +Add it temporarily to any client page when debugging, then remove it for design work. -## What is Capacitor? +--- -You can think of [Capacitor](https://capacitorjs.com/) as a sort of "electron for mobile" that runs standard web apps on iOS, Android, Desktop, and Web. +## Development workflow + user preferences (for future agents) + +### Preferences (must follow) +- **Use official docs** before proposing changes. If unsure, link the exact doc page. +- **Don’t invent** Clerk behavior or props; verify against docs. +- **Don’t build custom auth UIs** unless explicitly requested (use Clerk prebuilt components). +- **Always give copy/paste commands** using: + - `git add .` + - or the preferred one-liner: `npm run vercel -- "message"` +- Keep changes clean and reversible; avoid “one-off hacks” that linger. + +### Style preference +Tailwind-first styling. Only use raw CSS when necessary for Ionic theming or truly global concerns. + +--- + +## Environment variables (minimum) + +Set these in both local `.env.local` and Vercel: + +```bash +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_... +CLERK_SECRET_KEY=sk_... +``` -Capacitor provides access to Native APIs and a plugin system for building any native functionality your app needs. +If you use any redirect env vars, document them here and ensure they match existing routes. Avoid deprecated `NEXT_PUBLIC_CLERK_AFTER_*` variables. -Capacitor apps can also run in the browser as a Progressive Web App with the same code. diff --git a/android/.idea/.gitignore b/android/.idea/.gitignore new file mode 100644 index 00000000..26d33521 --- /dev/null +++ b/android/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/android/.idea/AndroidProjectSystem.xml b/android/.idea/AndroidProjectSystem.xml new file mode 100644 index 00000000..4a53bee8 --- /dev/null +++ b/android/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/android/.idea/compiler.xml b/android/.idea/compiler.xml new file mode 100644 index 00000000..b86273d9 --- /dev/null +++ b/android/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/.idea/deploymentTargetSelector.xml b/android/.idea/deploymentTargetSelector.xml new file mode 100644 index 00000000..e38794a0 --- /dev/null +++ b/android/.idea/deploymentTargetSelector.xml @@ -0,0 +1,18 @@ + + + + + + + + + \ No newline at end of file diff --git a/android/.idea/deviceManager.xml b/android/.idea/deviceManager.xml new file mode 100644 index 00000000..91f95584 --- /dev/null +++ b/android/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/android/.idea/migrations.xml b/android/.idea/migrations.xml new file mode 100644 index 00000000..f8051a6f --- /dev/null +++ b/android/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/android/.idea/misc.xml b/android/.idea/misc.xml new file mode 100644 index 00000000..74dd639e --- /dev/null +++ b/android/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/android/.idea/runConfigurations.xml b/android/.idea/runConfigurations.xml new file mode 100644 index 00000000..16660f1d --- /dev/null +++ b/android/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle index dee2c436..480c07b1 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,10 +1,20 @@ apply plugin: 'com.android.application' android { - namespace "com.example.app" + namespace "com.flowapp.app" compileSdkVersion rootProject.ext.compileSdkVersion + + buildFeatures { + buildConfig true + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + defaultConfig { - applicationId "com.example.app" + applicationId "com.flowapp.app" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 1 @@ -16,8 +26,24 @@ android { ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' } } + + signingConfigs { + release { + def keystorePropertiesFile = rootProject.file("keystore.properties") + def keystoreProperties = new Properties() + if (keystorePropertiesFile.exists()) { + keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) + storeFile file(keystoreProperties['storeFile']) + storePassword keystoreProperties['storePassword'] + keyAlias keystoreProperties['keyAlias'] + keyPassword keystoreProperties['keyPassword'] + } + } + } + buildTypes { release { + signingConfig signingConfigs.release minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index 52f8ca0c..30beb1cf 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -2,13 +2,19 @@ android { compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 } } apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" dependencies { + implementation project(':capacitor-app') + implementation project(':capacitor-app-launcher') + implementation project(':capacitor-browser') + implementation project(':capacitor-haptics') + implementation project(':capacitor-keyboard') + implementation project(':capacitor-preferences') implementation project(':capacitor-status-bar') } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 62bad318..a8b160a5 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -13,7 +13,7 @@ @@ -23,6 +23,14 @@ + + + + + + + + - nextjs-tailwind-capacitor - nextjs-tailwind-capacitor - com.example.app - com.example.app + Flow + Flow + com.flowapp.app + com.flowapp.app diff --git a/android/build.gradle b/android/build.gradle index 37d63ae4..84caad20 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -7,7 +7,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:8.0.0' + classpath 'com.android.tools.build:gradle:8.3.0' classpath 'com.google.gms:google-services:4.3.15' // NOTE: Do not place your application dependencies here; they belong @@ -24,6 +24,27 @@ allprojects { } } +subprojects { + afterEvaluate { project -> + if (project.hasProperty('android')) { + project.android { + if (project.android.hasProperty('compileOptions')) { + project.android.compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + } + } + } + + // Also set Java toolchain for all projects + tasks.withType(JavaCompile).configureEach { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + } +} + task clean(type: Delete) { delete rootProject.buildDir } diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index c736879f..405351a8 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -2,5 +2,23 @@ include ':capacitor-android' project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') +include ':capacitor-app' +project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android') + +include ':capacitor-app-launcher' +project(':capacitor-app-launcher').projectDir = new File('../node_modules/@capacitor/app-launcher/android') + +include ':capacitor-browser' +project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/browser/android') + +include ':capacitor-haptics' +project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android') + +include ':capacitor-keyboard' +project(':capacitor-keyboard').projectDir = new File('../node_modules/@capacitor/keyboard/android') + +include ':capacitor-preferences' +project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android') + include ':capacitor-status-bar' project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android') diff --git a/android/gradle.properties b/android/gradle.properties index 92710f31..6893649b 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -21,3 +21,6 @@ org.gradle.jvmargs=-Xmx1536m # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true +# Java version compatibility +org.gradle.java.home=/opt/homebrew/Cellar/openjdk@17/17.0.17/libexec/openjdk.jdk/Contents/Home + diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 761b8f08..309b4e18 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-all.zip networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/android/keystore.properties b/android/keystore.properties new file mode 100644 index 00000000..c53eaae0 --- /dev/null +++ b/android/keystore.properties @@ -0,0 +1,8 @@ +# This file contains sensitive signing information. +# DO NOT commit this file to version control. +# Copy this file to keystore.properties and fill in your actual values. + +storeFile=../../flowbalance-release-key.jks +storePassword=osD74FLEQJjKgXFxjwMW49aD +keyAlias=flowbalance-key +keyPassword=osD74FLEQJjKgXFxjwMW49aD diff --git a/android/variables.gradle b/android/variables.gradle index df5c0f24..6f46d798 100644 --- a/android/variables.gradle +++ b/android/variables.gradle @@ -1,7 +1,7 @@ ext { minSdkVersion = 22 - compileSdkVersion = 33 - targetSdkVersion = 33 + compileSdkVersion = 35 + targetSdkVersion = 35 androidxActivityVersion = '1.7.0' androidxAppCompatVersion = '1.6.1' androidxCoordinatorLayoutVersion = '1.2.0' diff --git a/app/[...all]/page.tsx b/app/[...all]/page.tsx index defd15bd..497458ad 100644 --- a/app/[...all]/page.tsx +++ b/app/[...all]/page.tsx @@ -1,5 +1,5 @@ import dynamic from 'next/dynamic'; -import { lists } from '../../mock'; +import { defaultFlows } from '../../data/flows'; const App = dynamic(() => import('../../components/AppShell'), { ssr: false, @@ -7,10 +7,16 @@ const App = dynamic(() => import('../../components/AppShell'), { export async function generateStaticParams() { return [ - { all: ['feed'] }, - { all: ['lists'] }, - ...lists.map(list => ({ all: ['lists', list.id] })), + { all: ['home'] }, + { all: ['flows'] }, { all: ['settings'] }, + { all: ['progress'] }, + ...defaultFlows.map(flow => ({ all: ['flows', flow.id] })), + ...defaultFlows.flatMap(flow => + flow.practices.map(practice => ({ + all: ['flows', flow.id, practice.id] + })) + ), ]; } diff --git a/app/api/create-sign-in-token/route.ts b/app/api/create-sign-in-token/route.ts new file mode 100644 index 00000000..382e6d60 --- /dev/null +++ b/app/api/create-sign-in-token/route.ts @@ -0,0 +1,27 @@ +import { auth, clerkClient } from '@clerk/nextjs/server'; +import { NextResponse } from 'next/server'; + +/** + * API route to create a transferable sign-in token + * This allows users to authenticate in external browsers + */ +export async function GET() { + try { + const { userId } = await auth(); + + if (!userId) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + // Create a sign-in token that can be used in external browser + const signInToken = await (await clerkClient()).signInTokens.createSignInToken({ + userId, + expiresInSeconds: 3600, // 1 hour expiry + }); + + return NextResponse.json({ token: signInToken.token }); + } catch (error) { + console.error('Error creating sign-in token:', error); + return NextResponse.json({ error: 'Failed to create token' }, { status: 500 }); + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 37398e26..79b7cb75 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata, Viewport } from 'next'; import Script from 'next/script'; +import { ClerkProvider } from '@clerk/nextjs'; import 'tailwindcss/tailwind.css'; /* Core CSS required for Ionic components to work properly */ @@ -22,8 +23,8 @@ import '../styles/global.css'; import '../styles/variables.css'; export const metadata: Metadata = { - title: 'Create Next App', - description: 'Generated by create next app', + title: 'Flow - Find Your Balance', + description: 'Meditation and wellness app', }; export const viewport: Viewport = { @@ -38,18 +39,33 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - {children} -