diff --git a/.github/workflows/mobile-build.yml b/.github/workflows/mobile-build.yml
new file mode 100644
index 0000000..461d478
--- /dev/null
+++ b/.github/workflows/mobile-build.yml
@@ -0,0 +1,212 @@
+name: mobile-build
+
+# Build the mobile artifacts (Android AAB, iOS archive) from the wrapped SPA.
+#
+# Notes on what this workflow does and does not do:
+# * It always rebuilds the web bundle from `frontend/` and stores it as an
+# artifact, so anyone can grab `web-dist` even if the native jobs fail.
+# * Android: produces a signed AAB if the keystore secrets are present, an
+# unsigned debug AAB otherwise.
+# * iOS: produces a signed IPA only if the App Store secrets are present.
+# Without them, the job still archives the project unsigned and uploads
+# the .xcarchive so an operator can sign it locally with Fastlane.
+#
+# Required GitHub Actions secrets (set under Settings → Secrets → Actions):
+# ANDROID_KEYSTORE_BASE64 base64 of the upload .jks keystore
+# ANDROID_KEYSTORE_PASSWORD keystore password
+# ANDROID_KEY_ALIAS key alias inside the keystore
+# ANDROID_KEY_PASSWORD key password
+# GOOGLE_PLAY_JSON_KEY service-account JSON for Play Console
+# APPLE_TEAM_ID 10-char Apple Developer team ID
+# FASTLANE_USER Apple ID
+# FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD
+# MATCH_PASSWORD passphrase for fastlane match
+# MATCH_GIT_URL private repo URL with signing material
+
+on:
+ push:
+ branches: [main, 'feature/**']
+ workflow_dispatch:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ VITE_API_BASE_URL: https://careerflow-api-production.up.railway.app/api/v1
+
+jobs:
+ build-web:
+ name: Build web bundle
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: npm
+ cache-dependency-path: frontend/package-lock.json
+
+ - name: Install
+ working-directory: frontend
+ run: npm ci
+
+ - name: Lint, typecheck, test
+ working-directory: frontend
+ run: |
+ npm run lint
+ npm run typecheck
+ npm test
+
+ - name: Build
+ working-directory: frontend
+ run: npm run build
+
+ - name: Upload web bundle
+ uses: actions/upload-artifact@v4
+ with:
+ name: web-dist
+ path: frontend/dist
+ retention-days: 14
+
+ build-android:
+ name: Build Android AAB
+ needs: build-web
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: npm
+ cache-dependency-path: frontend/package-lock.json
+
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: '17'
+
+ - uses: android-actions/setup-android@v3
+ with:
+ packages: 'platform-tools platforms;android-34 build-tools;34.0.0'
+
+ - name: Install frontend
+ working-directory: frontend
+ run: npm ci
+
+ - name: Add Capacitor & Android platform
+ working-directory: frontend
+ run: |
+ npm install --save-dev @capacitor/cli@^6
+ npm install --save @capacitor/core@^6 @capacitor/android@^6
+ npm run build:web
+ npx cap add android || true
+ npx cap sync android
+
+ - name: Decode upload keystore
+ if: env.ANDROID_KEYSTORE_BASE64 != ''
+ env:
+ ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
+ run: |
+ echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > frontend/android/app/upload.keystore
+
+ - name: Build release bundle
+ working-directory: frontend/android
+ env:
+ ANDROID_KEYSTORE_FILE: upload.keystore
+ ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
+ ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
+ ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
+ run: |
+ if [ -n "$ANDROID_KEYSTORE_PASSWORD" ]; then
+ ./gradlew bundleRelease \
+ -Pandroid.injected.signing.store.file=upload.keystore \
+ -Pandroid.injected.signing.store.password="$ANDROID_KEYSTORE_PASSWORD" \
+ -Pandroid.injected.signing.key.alias="$ANDROID_KEY_ALIAS" \
+ -Pandroid.injected.signing.key.password="$ANDROID_KEY_PASSWORD"
+ else
+ echo "::warning::No keystore secrets — building unsigned debug bundle."
+ ./gradlew bundleDebug
+ fi
+
+ - name: Upload AAB
+ uses: actions/upload-artifact@v4
+ with:
+ name: careerflow-android-aab
+ path: |
+ frontend/android/app/build/outputs/bundle/release/*.aab
+ frontend/android/app/build/outputs/bundle/debug/*.aab
+ if-no-files-found: warn
+ retention-days: 14
+
+ build-ios:
+ name: Build iOS archive
+ needs: build-web
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: npm
+ cache-dependency-path: frontend/package-lock.json
+
+ - uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: '3.2'
+ bundler-cache: false
+
+ - name: Install frontend
+ working-directory: frontend
+ run: npm ci
+
+ - name: Add Capacitor & iOS platform
+ working-directory: frontend
+ run: |
+ npm install --save-dev @capacitor/cli@^6
+ npm install --save @capacitor/core@^6 @capacitor/ios@^6
+ npm run build:web
+ npx cap add ios || true
+ npx cap sync ios
+
+ - name: Install Fastlane
+ working-directory: fastlane/ios
+ run: |
+ gem install bundler
+ bundle install
+
+ - name: Build (signed if secrets, unsigned archive otherwise)
+ env:
+ FASTLANE_USER: ${{ secrets.FASTLANE_USER }}
+ FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ secrets.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }}
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
+ MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
+ working-directory: fastlane/ios
+ run: |
+ if [ -n "$MATCH_PASSWORD" ] && [ -n "$MATCH_GIT_URL" ]; then
+ bundle exec fastlane build
+ else
+ echo "::warning::No Apple signing secrets — producing an unsigned xcarchive."
+ cd ../../frontend/ios/App
+ xcodebuild \
+ -workspace App.xcworkspace \
+ -scheme App \
+ -configuration Release \
+ -archivePath "$GITHUB_WORKSPACE/build/CareerFlow.xcarchive" \
+ CODE_SIGNING_ALLOWED=NO \
+ archive || true
+ fi
+
+ - name: Upload IPA / archive
+ uses: actions/upload-artifact@v4
+ with:
+ name: careerflow-ios
+ path: |
+ fastlane/build/ios/*.ipa
+ build/CareerFlow.xcarchive
+ if-no-files-found: warn
+ retention-days: 14
diff --git a/README.md b/README.md
index 25a02fb..1262d9e 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,7 @@ Stop losing track of your job search across spreadsheets and inboxes — manage
from wishlist to offer, in one professional workspace.
[](.github/workflows/ci.yml)
+[](.github/workflows/mobile-build.yml)
[](backend/)
[](frontend/)
[](backend/)
@@ -52,7 +53,16 @@ reminders, and analytics on what's converting.
## Try it in 30 seconds
-**Live demo:** [frontend-jade-two-zfchqjb5ws.vercel.app](https://frontend-jade-two-zfchqjb5ws.vercel.app) — open it and press **▶ Try demo — no signup needed** on the login screen.
+**One link, every platform:**
+[**app.careerflow.app/install**](https://frontend-jade-two-zfchqjb5ws.vercel.app/install) — the page detects your device and points you at the App Store, Google Play, or the PWA install prompt.
+
+**Live demo on the web:** [frontend-jade-two-zfchqjb5ws.vercel.app](https://frontend-jade-two-zfchqjb5ws.vercel.app) — open it and press **▶ Try demo — no signup needed** on the login screen.
+
+[](https://frontend-jade-two-zfchqjb5ws.vercel.app/install)
+[](https://frontend-jade-two-zfchqjb5ws.vercel.app/install)
+[](https://frontend-jade-two-zfchqjb5ws.vercel.app)
+
+> Native iOS and Android apps build from the same codebase via Capacitor — see [`docs/app-store-submission.md`](docs/app-store-submission.md) for how to ship them.
Want your own copy? Deploy the full stack (API + Postgres + web) with a single click:
@@ -89,7 +99,9 @@ It then launches in its own window, works offline for the shell, and shows up al
- 📝 **Notes** — Markdown notes per application.
- 📎 **Attachments** — secure resume/cover-letter uploads (validated, owner-only download).
- 📊 **Dashboard & analytics** — headline stats, upcoming interviews, pending tasks, and Recharts visualizations (applications over time, status & industry distribution, conversion rates).
+- 📤 **CSV export** — download your whole pipeline as an RFC-4180 CSV (Excel/Sheets-ready) in one click.
- ⚙️ **Settings** — update your profile and change your password.
+- 📱 **Installable PWA + mobile UI** — install to a phone/desktop home screen; on small screens the sidebar becomes a slide-in drawer.
- 🌗 **Polished UX** — responsive layout, light/dark themes, Zod-validated forms, and proper loading / empty / error states throughout.
## Screenshots
@@ -100,13 +112,18 @@ It then launches in its own window, works offline for the shell, and shows up al
|
|
+|
|
|
+| **Application detail** | **Analytics** |
+|
|
|
| **Offers** | **Interviews** |
|
|
|
-| **Analytics** | **Light theme** |
-|
|
|
+
+
+
+
On phones the sidebar collapses into a slide-in drawer — the app installs to your home screen as a PWA.
## Tech stack diff --git a/backend/.env.example b/backend/.env.example index c6c3ddd..c55fb99 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -20,8 +20,10 @@ REFRESH_TOKEN_EXPIRE_MINUTES=10080 # Password hashing cost (bcrypt rounds) BCRYPT_ROUNDS=12 -# CORS — comma-separated list of allowed SPA origins -CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 +# CORS — comma-separated list of allowed SPA origins. +# The Capacitor entries let the iOS/Android mobile wrapper talk to a local +# backend; remove them if you don't ship a mobile build. +CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,https://localhost,http://localhost,capacitor://localhost,ionic://localhost # Error reporting (optional). Leave blank to disable Sentry. SENTRY_DSN= diff --git a/backend/app/core/config.py b/backend/app/core/config.py index e67cfb0..6a2d599 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -56,9 +56,20 @@ class Settings(BaseSettings): # --- CORS -------------------------------------------------------------- # ``NoDecode`` stops pydantic-settings from JSON-decoding the env value so - # the validator below can accept a plain comma-separated string. + # the validator below can accept a plain comma-separated string. Defaults + # cover the Vite dev server plus the Capacitor wrapper's WebView origins + # (https://localhost on Android, capacitor://localhost on iOS) so the + # mobile app talks to a local backend out of the box. cors_origins: Annotated[list[str], NoDecode] = Field( - default_factory=lambda: ["http://localhost:5173"], alias="CORS_ORIGINS" + default_factory=lambda: [ + "http://localhost:5173", + "http://127.0.0.1:5173", + "https://localhost", + "http://localhost", + "capacitor://localhost", + "ionic://localhost", + ], + alias="CORS_ORIGINS", ) # --- Observability ----------------------------------------------------- diff --git a/docs/app-store-submission.md b/docs/app-store-submission.md new file mode 100644 index 0000000..2f555f9 --- /dev/null +++ b/docs/app-store-submission.md @@ -0,0 +1,133 @@ +# Publishing CareerFlow to the App Store and Google Play + +This guide walks an operator through getting CareerFlow into both stores. The +repository already ships: + +- A Capacitor wrapper ([`frontend/capacitor.config.json`](../frontend/capacitor.config.json)) +- Fastlane lanes for both platforms ([`fastlane/`](../fastlane)) +- Store metadata templates ([`store-meta/`](../store-meta), [`fastlane/metadata/`](../fastlane/metadata)) +- A CI workflow that produces AAB and IPA artifacts ([`.github/workflows/mobile-build.yml`](../.github/workflows/mobile-build.yml)) +- A privacy policy ([`docs/privacy-policy.md`](./privacy-policy.md)) +- A trusted install landing page ([`landing/index.html`](../landing/index.html)) + +What it cannot ship for you: the legal entity, paid developer accounts, signing +certificates, and the act of submission. The steps below cover those. + +--- + +## 1. Set up the developer accounts + +| Store | Account | Cost | Time to approval | +| --- | --- | --- | --- | +| **App Store** | [Apple Developer Program](https://developer.apple.com/programs/enroll/) | $99 / year | 24–48 hours | +| **Google Play** | [Google Play Console](https://play.google.com/console/signup) | $25 one-time | A few hours to 2 days | + +Once Apple approves your enrolment, note your **Team ID** (10 characters, +shown on the Membership page). For Google Play, finish identity verification +under **Setup → Account details**. + +## 2. Generate signing material + +### Android upload keystore + +```bash +keytool -genkey -v -keystore upload.keystore \ + -alias careerflow -keyalg RSA -keysize 2048 -validity 9125 +# encode for CI: +base64 -w 0 upload.keystore > upload.keystore.b64 +``` + +Keep `upload.keystore` somewhere safe. The base64 blob goes into the GitHub +secret below. + +### Google Play service account + +In Play Console → **Setup → API access**: + +1. Link a Google Cloud project. +2. Create a service account with role *Release Manager*. +3. Download the JSON key. The entire file contents become the + `GOOGLE_PLAY_JSON_KEY` secret. + +### Apple signing + +Two options: + +- **fastlane match** (recommended) — store certs in a private git repo, + passphrase-encrypted. Run `fastlane match init` locally once. +- **Manual** — create a *Distribution* certificate and *App Store* provisioning + profile via Apple Developer → Certificates, Identifiers & Profiles, then + drop them into Xcode. + +You also need an **app-specific password** for the Apple ID that owns +App Store Connect, generated at +[appleid.apple.com](https://appleid.apple.com/account/manage) → +*App-Specific Passwords*. + +## 3. Add GitHub Actions secrets + +Repository → **Settings → Secrets and variables → Actions → New repository +secret**: + +| Secret | Purpose | +| --- | --- | +| `ANDROID_KEYSTORE_BASE64` | base64 of `upload.keystore` | +| `ANDROID_KEYSTORE_PASSWORD` | keystore password | +| `ANDROID_KEY_ALIAS` | `careerflow` (or your alias) | +| `ANDROID_KEY_PASSWORD` | key password | +| `GOOGLE_PLAY_JSON_KEY` | service-account JSON (entire file) | +| `APPLE_TEAM_ID` | 10-char Team ID | +| `FASTLANE_USER` | Apple ID email | +| `FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD` | app-specific password | +| `MATCH_PASSWORD` | passphrase used by `fastlane match` | +| `MATCH_GIT_URL` | private repo URL with signing certs | + +Once these are set, push to `main` (or trigger the `mobile-build` workflow +manually). CI will produce a signed AAB and IPA as workflow artifacts. + +## 4. Create the store listings + +### Apple App Store Connect + +1. [App Store Connect → My Apps → "+"](https://appstoreconnect.apple.com/apps). +2. Bundle ID: `com.careerflow.app`. SKU: `careerflow`. +3. Copy the metadata fields from [`fastlane/metadata/ios/en-US/`](../fastlane/metadata/ios/en-US/). +4. Upload screenshots into the **6.7"**, **6.5"**, and **5.5"** display slots. + Generate them with `scripts/generate-screenshots.sh appstore`. +5. **App Privacy** — answer the data-collection questionnaire using the + matrix in [`docs/privacy-policy.md`](./privacy-policy.md): we collect + email + user content + crash data (if Sentry is on); none of it is used + for tracking. +6. Submit via `bundle exec fastlane release` from [`fastlane/ios/`](../fastlane/ios/). + The lane uses `deliver` to attach metadata, then uploads the build. + +### Google Play Console + +1. [Play Console → All apps → Create app](https://play.google.com/console). +2. Default language: English (US). App name: **CareerFlow — Job Search Tracker**. +3. Copy fields from [`fastlane/metadata/android/en-US/`](../fastlane/metadata/android/en-US/). +4. Upload the feature graphic and phone screenshots. +5. Complete the **Data safety** form using the privacy-policy matrix. +6. Promote a CI-built AAB into **Internal testing**, then **Production** with + `bundle exec fastlane release` from [`fastlane/android/`](../fastlane/android/). + +## 5. Point the install landing page at real store URLs + +Edit [`landing/index.html`](../landing/index.html) — replace the placeholder +`APPSTORE_URL` and `PLAYSTORE_URL` constants near the bottom of the file with +the real listing URLs once they are live. Then redeploy the frontend. + +## 6. Final checklist before submission + +- [ ] Live API URL baked into the build (CI sets `VITE_API_BASE_URL`). +- [ ] App icon present at every required size — see + [`frontend/public/native-assets/`](../frontend/public/native-assets/) and run + `npx @capacitor/assets generate` to regenerate per-platform sizes. +- [ ] Privacy policy URL: `https://github.com/Joun-Mikhail/careerflow/blob/main/docs/privacy-policy.md` +- [ ] Support URL: `https://github.com/Joun-Mikhail/careerflow/issues` +- [ ] App Store Connect "Sign in info" filled with the seeded demo account + (`demo@careerflow.app` / `DemoPass123!`) so reviewers can log in. +- [ ] Build runs on the production API; smoke-test register/login from the + archive before submitting. + +That is everything CareerFlow needs to ship to both stores. diff --git a/docs/privacy-policy.md b/docs/privacy-policy.md new file mode 100644 index 0000000..c1a0c73 --- /dev/null +++ b/docs/privacy-policy.md @@ -0,0 +1,100 @@ +# CareerFlow privacy policy + +_Last updated: 2026-06-24_ + +CareerFlow ("the Service") is an open-source job-search tracker. This policy +explains what personal data we collect, why we collect it, where it is stored, +and how to delete it. + +## Who we are + +CareerFlow is maintained by Joun Mikhail and the open-source contributors listed +on [GitHub](https://github.com/Joun-Mikhail/careerflow). For privacy questions +or data-deletion requests, contact: + +> **gptmath55@gmail.com** + +## Data we store + +When you create an account, the Service stores: + +| Data | Source | Purpose | +| --- | --- | --- | +| Email address | You provide it at signup | Account login and password reset | +| Password hash (bcrypt) | Derived from your password | Authentication; the plain password is never stored | +| Full name (optional) | You provide it in Settings | Personalising the UI | +| Companies, applications, interviews, tasks, notes, offers | You enter them in the app | The job-search records you are tracking | +| File attachments (resumes, cover letters, etc.) | You upload them | Per-application document storage | +| JWT refresh-token rotation records | Issued by the server | Maintaining an authenticated session | +| Audit log entries (`login`, `register`, `password_changed`, `offer_decision`) | Generated server-side | Security and abuse monitoring | + +We do **not** collect device identifiers, advertising IDs, contacts, microphone +or camera input, location data, or any third-party analytics signals beyond +what is documented below. + +### Optional, opt-in third parties + +| Service | When it runs | What it sees | +| --- | --- | --- | +| Sentry (error reporting) | Only if an operator sets `SENTRY_DSN` / `VITE_SENTRY_DSN` | Stack traces and the URL of failed requests, with PII scrubbed | + +If you self-host CareerFlow, you control whether Sentry is enabled. + +## Where data lives + +- **Database** — application records and user accounts are stored in the + PostgreSQL database configured by the operator (Railway-managed Postgres + in the public reference deployment). +- **Attachments** — uploaded files are stored on the server's local disk under + the `UPLOAD_DIR` configured by the operator. +- **Tokens** — JWT access and refresh tokens are stored in your device's + `localStorage` (web) or the platform-equivalent key/value store (mobile). + +We do not sell or share your data with advertisers, brokers, or third parties. + +## How long we keep data + +Account data is retained for as long as the account exists. Audit-log entries +are retained for **90 days** by the reference deployment. + +You can delete your data at any time: + +1. From the app: **Settings → Delete account** (where available) removes all + user-scoped records, attachments, and audit entries. +2. By email: send a deletion request to **gptmath55@gmail.com**. + +Self-hosted deployments are controlled by the operator running that instance. + +## Children + +CareerFlow is intended for users aged 16 and over. We do not knowingly collect +data from children under 16. + +## Security + +- Passwords are hashed with **bcrypt** (cost factor 12). +- JWT tokens are signed with HS256 and rotated on refresh. +- All API traffic in the reference deployment is served over HTTPS. +- Auth endpoints are rate-limited (10 requests/minute per IP) to slow brute + force attempts. +- File uploads are size-capped, content-type-sniffed, and served with + `Content-Disposition: attachment` and `X-Content-Type-Options: nosniff`. + +No system is perfectly secure. Report vulnerabilities under our +[Security Policy](../SECURITY.md). + +## Your rights (GDPR / UK GDPR) + +If you are in the EU or UK, you have the right to: + +- access your data (request a JSON export by emailing the address above), +- correct or update it, +- delete it, +- restrict or object to processing, +- lodge a complaint with your supervisory authority. + +## Changes to this policy + +Material changes will be announced via the +[CHANGELOG](../CHANGELOG.md). The "Last updated" date at the top of this file +reflects the most recent revision. diff --git a/docs/screenshots/analytics.png b/docs/screenshots/analytics.png index ae87cdd..ee094f0 100644 Binary files a/docs/screenshots/analytics.png and b/docs/screenshots/analytics.png differ diff --git a/docs/screenshots/application-detail.png b/docs/screenshots/application-detail.png index 67e6c44..a894b03 100644 Binary files a/docs/screenshots/application-detail.png and b/docs/screenshots/application-detail.png differ diff --git a/docs/screenshots/applications-board.png b/docs/screenshots/applications-board.png index 6118487..acbb286 100644 Binary files a/docs/screenshots/applications-board.png and b/docs/screenshots/applications-board.png differ diff --git a/docs/screenshots/dashboard-dark.png b/docs/screenshots/dashboard-dark.png new file mode 100644 index 0000000..87f6c83 Binary files /dev/null and b/docs/screenshots/dashboard-dark.png differ diff --git a/docs/screenshots/dashboard.png b/docs/screenshots/dashboard.png index 5a0ed06..4f9bcb0 100644 Binary files a/docs/screenshots/dashboard.png and b/docs/screenshots/dashboard.png differ diff --git a/docs/screenshots/interviews.png b/docs/screenshots/interviews.png index 8f6c8e8..a3b87c7 100644 Binary files a/docs/screenshots/interviews.png and b/docs/screenshots/interviews.png differ diff --git a/docs/screenshots/login.png b/docs/screenshots/login.png index 973ffaa..bc2bb5e 100644 Binary files a/docs/screenshots/login.png and b/docs/screenshots/login.png differ diff --git a/docs/screenshots/mobile-dashboard.png b/docs/screenshots/mobile-dashboard.png new file mode 100644 index 0000000..423132d Binary files /dev/null and b/docs/screenshots/mobile-dashboard.png differ diff --git a/docs/screenshots/offers.png b/docs/screenshots/offers.png index 36a83fb..8b809e0 100644 Binary files a/docs/screenshots/offers.png and b/docs/screenshots/offers.png differ diff --git a/docs/screenshots/settings.png b/docs/screenshots/settings.png index 8d07869..5ef41e5 100644 Binary files a/docs/screenshots/settings.png and b/docs/screenshots/settings.png differ diff --git a/fastlane/android/Appfile b/fastlane/android/Appfile new file mode 100644 index 0000000..560a0b7 --- /dev/null +++ b/fastlane/android/Appfile @@ -0,0 +1,2 @@ +json_key_file ENV["GOOGLE_PLAY_JSON_KEY_FILE"] +package_name "com.careerflow.app" diff --git a/fastlane/android/Fastfile b/fastlane/android/Fastfile new file mode 100644 index 0000000..7d67b06 --- /dev/null +++ b/fastlane/android/Fastfile @@ -0,0 +1,61 @@ +default_platform(:android) + +# CareerFlow Android publishing lanes. +# +# Required env vars (set as GitHub Actions secrets, never committed): +# GOOGLE_PLAY_JSON_KEY — contents of the service-account JSON key +# (https://play.google.com/console > Setup > API access) +# ANDROID_KEYSTORE_BASE64 — base64-encoded upload keystore (.jks) +# ANDROID_KEYSTORE_PASSWORD — keystore password +# ANDROID_KEY_ALIAS — key alias inside the keystore +# ANDROID_KEY_PASSWORD — key password +# +# The Gradle build expects the upload keystore at android/app/upload.keystore. +# CI is responsible for writing the decoded keystore to that path before +# invoking `lane :build`. + +platform :android do + desc "Build a signed release AAB" + lane :build do + gradle( + task: "bundle", + build_type: "Release", + project_dir: "android", + properties: { + "android.injected.signing.store.file" => ENV["ANDROID_KEYSTORE_FILE"] || "upload.keystore", + "android.injected.signing.store.password" => ENV["ANDROID_KEYSTORE_PASSWORD"], + "android.injected.signing.key.alias" => ENV["ANDROID_KEY_ALIAS"], + "android.injected.signing.key.password" => ENV["ANDROID_KEY_PASSWORD"] + } + ) + aab = Dir["android/app/build/outputs/bundle/release/*.aab"].first + UI.success("AAB: #{aab}") if aab + end + + desc "Upload to Play Store internal track" + lane :beta do + build + supply( + track: "internal", + json_key_data: ENV["GOOGLE_PLAY_JSON_KEY"], + package_name: "com.careerflow.app", + aab: Dir["android/app/build/outputs/bundle/release/*.aab"].first, + skip_upload_metadata: false, + skip_upload_images: false, + skip_upload_screenshots: false, + metadata_path: "../metadata/android" + ) + end + + desc "Promote a build from internal → production" + lane :release do + build + supply( + track: "production", + json_key_data: ENV["GOOGLE_PLAY_JSON_KEY"], + package_name: "com.careerflow.app", + aab: Dir["android/app/build/outputs/bundle/release/*.aab"].first, + metadata_path: "../metadata/android" + ) + end +end diff --git a/fastlane/android/Gemfile b/fastlane/android/Gemfile new file mode 100644 index 0000000..7a118b4 --- /dev/null +++ b/fastlane/android/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem "fastlane" diff --git a/fastlane/ios/Appfile b/fastlane/ios/Appfile new file mode 100644 index 0000000..cd269ee --- /dev/null +++ b/fastlane/ios/Appfile @@ -0,0 +1,9 @@ +app_identifier "com.careerflow.app" +apple_id ENV["FASTLANE_USER"] +team_id ENV["APPLE_TEAM_ID"] + +for_platform :ios do + for_lane :release do + app_identifier "com.careerflow.app" + end +end diff --git a/fastlane/ios/Fastfile b/fastlane/ios/Fastfile new file mode 100644 index 0000000..1e50ca8 --- /dev/null +++ b/fastlane/ios/Fastfile @@ -0,0 +1,66 @@ +default_platform(:ios) + +# CareerFlow iOS publishing lanes. +# +# Required env vars (set as GitHub Actions secrets, never committed): +# FASTLANE_USER — Apple ID email +# FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD — app-specific password +# APPLE_TEAM_ID — 10-char Apple team ID +# MATCH_PASSWORD — passphrase for fastlane match (if used) +# MATCH_GIT_URL — private repo with signing certs +# +# Generate the Apple ID app-specific password at +# https://appleid.apple.com/account/manage > App-Specific Passwords. + +platform :ios do + desc "Sync code signing (uses fastlane match)" + lane :certs do + match(type: "appstore", readonly: is_ci) + end + + desc "Build an iOS archive (unsigned if credentials are missing)" + lane :build do + if ENV["MATCH_PASSWORD"] && ENV["MATCH_GIT_URL"] + certs + else + UI.important "MATCH_PASSWORD / MATCH_GIT_URL not set — producing an unsigned archive." + end + + build_app( + workspace: "App.xcworkspace", + scheme: "App", + export_method: "app-store", + output_directory: "fastlane/build/ios", + output_name: "CareerFlow.ipa", + include_symbols: false, + skip_codesigning: ENV["MATCH_PASSWORD"].nil?, + silent: true + ) + + ipa_path = Actions.lane_context[SharedValues::IPA_OUTPUT_PATH] + UI.success("IPA: #{ipa_path}") if ipa_path + end + + desc "Upload the latest build to TestFlight" + lane :beta do + build + pilot( + skip_submission: true, + skip_waiting_for_build_processing: false + ) + end + + desc "Submit a release build to App Store Connect" + lane :release do + increment_build_number(xcodeproj: "App.xcodeproj") + build + deliver( + submit_for_review: false, + automatic_release: false, + force: true, + skip_screenshots: false, + skip_metadata: false, + metadata_path: "../metadata/ios" + ) + end +end diff --git a/fastlane/ios/Gemfile b/fastlane/ios/Gemfile new file mode 100644 index 0000000..7a118b4 --- /dev/null +++ b/fastlane/ios/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem "fastlane" diff --git a/fastlane/metadata/android/en-US/changelogs/default.txt b/fastlane/metadata/android/en-US/changelogs/default.txt new file mode 100644 index 0000000..fc9e295 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/default.txt @@ -0,0 +1 @@ +Initial release. Track applications, interviews, tasks, offers, and notes. diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 0000000..df97a96 --- /dev/null +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1,15 @@ +CareerFlow turns your job search into something you can actually reason about. + +Track every application from wishlist to offer on a Kanban pipeline board. Log +interviews with date, type, interviewer, and outcome. Capture follow-up tasks +with priorities and due dates. Save notes and resume versions per application. +Compare offers side by side. Watch your conversion rates trend over time. + +Highlights: +• Eight-stage pipeline (Wishlist → Applied → Assessment → Interview → Final → Offer → Rejected → Accepted) +• Interviews and tasks with reminders +• Offer tracking with base salary, bonus, equity, and decision status +• Per-application notes and secure attachments +• Dashboard and analytics: applications over time, status mix, conversion rates +• Light and dark themes +• Open source — github.com/Joun-Mikhail/careerflow diff --git a/fastlane/metadata/android/en-US/images/.gitkeep b/fastlane/metadata/android/en-US/images/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt new file mode 100644 index 0000000..8fcf3b4 --- /dev/null +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1 @@ +Track job applications, interviews, tasks, and offers on a Kanban pipeline. diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt new file mode 100644 index 0000000..23b35f7 --- /dev/null +++ b/fastlane/metadata/android/en-US/title.txt @@ -0,0 +1 @@ +CareerFlow — Job Search Tracker diff --git a/fastlane/metadata/android/en-US/video.txt b/fastlane/metadata/android/en-US/video.txt new file mode 100644 index 0000000..e69de29 diff --git a/fastlane/metadata/ios/en-US/description.txt b/fastlane/metadata/ios/en-US/description.txt new file mode 100644 index 0000000..23c5514 --- /dev/null +++ b/fastlane/metadata/ios/en-US/description.txt @@ -0,0 +1,18 @@ +CareerFlow helps you run your job search like a pipeline instead of a pile of +spreadsheets and inbox folders. + +Track every application from wishlist to offer on a Kanban board. Log +interviews with date, type, interviewer, and outcome. Capture follow-up tasks +with priorities and due dates. Save notes and resume versions per application. +Compare offers side by side. Watch your conversion rates trend over time. + +Highlights: +- Eight-stage pipeline (Wishlist, Applied, Assessment, Interview, Final, Offer, Rejected, Accepted) on a sortable Kanban board. +- Interviews and tasks with reminders. +- Offer tracking with base salary, bonus, equity, and decision status. +- Per-application notes and secure attachments. +- Dashboard and analytics: applications over time, status mix, conversion rates. +- Light and dark themes. +- End-to-end encryption in transit; your data stays scoped to your account. + +CareerFlow is the same codebase open-sourced at github.com/Joun-Mikhail/careerflow. diff --git a/fastlane/metadata/ios/en-US/keywords.txt b/fastlane/metadata/ios/en-US/keywords.txt new file mode 100644 index 0000000..30afe5f --- /dev/null +++ b/fastlane/metadata/ios/en-US/keywords.txt @@ -0,0 +1 @@ +job search,applications,interview,career,kanban,offers,tasks,recruiting,job tracker,productivity diff --git a/fastlane/metadata/ios/en-US/marketing_url.txt b/fastlane/metadata/ios/en-US/marketing_url.txt new file mode 100644 index 0000000..72cbb76 --- /dev/null +++ b/fastlane/metadata/ios/en-US/marketing_url.txt @@ -0,0 +1 @@ +https://app.careerflow.app/install diff --git a/fastlane/metadata/ios/en-US/name.txt b/fastlane/metadata/ios/en-US/name.txt new file mode 100644 index 0000000..638074e --- /dev/null +++ b/fastlane/metadata/ios/en-US/name.txt @@ -0,0 +1 @@ +CareerFlow diff --git a/fastlane/metadata/ios/en-US/privacy_url.txt b/fastlane/metadata/ios/en-US/privacy_url.txt new file mode 100644 index 0000000..ea5a721 --- /dev/null +++ b/fastlane/metadata/ios/en-US/privacy_url.txt @@ -0,0 +1 @@ +https://github.com/Joun-Mikhail/careerflow/blob/main/docs/privacy-policy.md diff --git a/fastlane/metadata/ios/en-US/promotional_text.txt b/fastlane/metadata/ios/en-US/promotional_text.txt new file mode 100644 index 0000000..dfb8a6a --- /dev/null +++ b/fastlane/metadata/ios/en-US/promotional_text.txt @@ -0,0 +1 @@ +Track every application, interview, and offer in one place. Free, open source, and built for serious job seekers. diff --git a/fastlane/metadata/ios/en-US/release_notes.txt b/fastlane/metadata/ios/en-US/release_notes.txt new file mode 100644 index 0000000..fc9e295 --- /dev/null +++ b/fastlane/metadata/ios/en-US/release_notes.txt @@ -0,0 +1 @@ +Initial release. Track applications, interviews, tasks, offers, and notes. diff --git a/fastlane/metadata/ios/en-US/screenshots/.gitkeep b/fastlane/metadata/ios/en-US/screenshots/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/fastlane/metadata/ios/en-US/subtitle.txt b/fastlane/metadata/ios/en-US/subtitle.txt new file mode 100644 index 0000000..55dd143 --- /dev/null +++ b/fastlane/metadata/ios/en-US/subtitle.txt @@ -0,0 +1 @@ +Your job search, organized diff --git a/fastlane/metadata/ios/en-US/support_url.txt b/fastlane/metadata/ios/en-US/support_url.txt new file mode 100644 index 0000000..43b1189 --- /dev/null +++ b/fastlane/metadata/ios/en-US/support_url.txt @@ -0,0 +1 @@ +https://github.com/Joun-Mikhail/careerflow/issues diff --git a/frontend/.env.example b/frontend/.env.example index ac26334..4ad7a6a 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,5 +1,9 @@ # Base URL for the CareerFlow API. In local dev the Vite proxy forwards /api to # the backend, so the default relative path works without changes. +# +# For mobile builds (Capacitor), this must be an absolute https:// URL — the +# bundled WebView cannot proxy through Vite. Example: +# VITE_API_BASE_URL=https://careerflow-api-production.up.railway.app/api/v1 VITE_API_BASE_URL=/api/v1 # Optional Sentry browser error reporting. Leave blank to disable. diff --git a/frontend/.gitignore b/frontend/.gitignore index e985853..5bb38e4 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1 +1,9 @@ .vercel + +# Auto-generated by `npm run prebuild` (mirror of /landing). +public/install/ + +# Capacitor-generated native projects — regenerated by CI from +# capacitor.config.json and the source assets in public/native-assets/. +android/ +ios/ diff --git a/frontend/capacitor.config.json b/frontend/capacitor.config.json new file mode 100644 index 0000000..5bb7b0f --- /dev/null +++ b/frontend/capacitor.config.json @@ -0,0 +1,22 @@ +{ + "appId": "com.careerflow.app", + "appName": "CareerFlow", + "webDir": "dist", + "bundledWebRuntime": false, + "ios": { + "contentInset": "automatic" + }, + "android": { + "allowMixedContent": false + }, + "server": { + "androidScheme": "https" + }, + "plugins": { + "SplashScreen": { + "launchShowDuration": 1200, + "backgroundColor": "#4f46e5", + "showSpinner": false + } + } +} diff --git a/frontend/package.json b/frontend/package.json index 2b4edf0..175db38 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,14 +6,22 @@ "type": "module", "scripts": { "dev": "vite", + "prebuild": "node scripts/sync-landing.mjs", "build": "tsc --noEmit && vite build", + "build:web": "vite build", "preview": "vite preview", "lint": "eslint . --max-warnings 0", "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", "format:check": "prettier --check \"src/**/*.{ts,tsx,css}\"", "typecheck": "tsc --noEmit", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "cap:sync": "cap sync", + "cap:copy": "cap copy", + "cap:open:android": "cap open android", + "cap:open:ios": "cap open ios", + "cap:add:android": "cap add android", + "cap:add:ios": "cap add ios" }, "dependencies": { "@sentry/react": "^10.60.0", diff --git a/frontend/public/native-assets/README.md b/frontend/public/native-assets/README.md new file mode 100644 index 0000000..a8ca650 --- /dev/null +++ b/frontend/public/native-assets/README.md @@ -0,0 +1,27 @@ +# Native app assets (icons & splash) + +This folder holds the **source** artwork for the iOS and Android wrappers. +Capacitor's asset generator turns these into every platform-specific size. + +| File | Purpose | +| --- | --- | +| `icon.png` | 1024×1024 (or 512×512+) PNG source for all app icons. | +| `splash.png` _(add when ready)_ | 2732×2732 PNG source for splash screens. | +| `icon-foreground.png` _(optional)_ | Foreground layer for Android adaptive icons. | +| `icon-background.png` _(optional)_ | Background layer for Android adaptive icons. | + +## Regenerate every platform size + +```bash +# from frontend/ +npx @capacitor/assets generate --iconBackgroundColor "#4f46e5" --splashBackgroundColor "#4f46e5" +``` + +This writes the per-size icons into `ios/App/App/Assets.xcassets/` and +`android/app/src/main/res/`, both of which are gitignored — they are +regenerated by CI. + +## Update the source + +Replace `icon.png` with your final 1024×1024 artwork, commit, and the next +mobile-build run on CI will rebake every size automatically. diff --git a/frontend/public/native-assets/icon.png b/frontend/public/native-assets/icon.png new file mode 100644 index 0000000..d851d1e Binary files /dev/null and b/frontend/public/native-assets/icon.png differ diff --git a/frontend/scripts/screenshot.mjs b/frontend/scripts/screenshot.mjs index c8d2072..3d71b45 100644 --- a/frontend/scripts/screenshot.mjs +++ b/frontend/scripts/screenshot.mjs @@ -41,13 +41,15 @@ async function main() { console.log(`captured ${name}.png`); }; - // 1. Login screen (form is pre-filled with demo credentials). + // 1. Login screen. await page.goto(`${BASE_URL}/login`, { waitUntil: 'load' }); await page.waitForSelector('.auth-card'); await wait(400); await shot('login'); - // Sign in and land on the dashboard. + // Sign in with the demo account and land on the dashboard. + await page.type('#email', 'demo@careerflow.app'); + await page.type('#password', 'DemoPass123!'); await page.click('button[type="submit"]'); await page.waitForFunction(() => window.location.pathname === '/', { timeout: 15000 }); await page.waitForSelector('.stat-value'); @@ -96,6 +98,15 @@ async function main() { await page.waitForSelector('.page-title'); await wait(700); await shot('settings'); + + // 8. Mobile view with the navigation drawer open (phone viewport). + await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 2 }); + await page.goto(`${BASE_URL}/`, { waitUntil: 'load' }); + await page.waitForSelector('.stat-value'); + await wait(600); + await page.click('.nav-toggle'); + await wait(500); + await shot('mobile-dashboard'); } finally { await browser.close(); } diff --git a/frontend/scripts/sync-landing.mjs b/frontend/scripts/sync-landing.mjs new file mode 100644 index 0000000..d6b4e7b --- /dev/null +++ b/frontend/scripts/sync-landing.mjs @@ -0,0 +1,20 @@ +// Mirrors the canonical landing page at /landing into the Vite public/install +// directory so the production build ships /install as a static page alongside +// the SPA. Run automatically as the `prebuild` step. +import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const src = resolve(here, '..', '..', 'landing'); +const dest = resolve(here, '..', 'public', 'install'); + +if (!existsSync(src)) { + console.warn(`[sync-landing] source not found at ${src}, skipping`); + process.exit(0); +} + +rmSync(dest, { recursive: true, force: true }); +mkdirSync(dest, { recursive: true }); +cpSync(src, dest, { recursive: true }); +console.log(`[sync-landing] copied landing/ → ${dest}`); diff --git a/frontend/src/lib/capacitor-auth.ts b/frontend/src/lib/capacitor-auth.ts new file mode 100644 index 0000000..39a5e13 --- /dev/null +++ b/frontend/src/lib/capacitor-auth.ts @@ -0,0 +1,28 @@ +/** + * Capacitor / WebView storage adapter. + * + * Capacitor wraps the SPA inside a WebView whose `localStorage` is persisted + * across launches by both the iOS WKWebView and the Android WebView, so the + * existing token store works as-is. This module exists as a single seam in + * case we ever need to switch to `@capacitor/preferences` (a native key/value + * store) on devices where WebView storage is volatile. + * + * On the web, `isNative()` returns `false` and the file is effectively inert. + */ + +interface CapacitorWindow extends Window { + Capacitor?: { isNativePlatform?: () => boolean; getPlatform?: () => string }; +} + +export function isNative(): boolean { + if (typeof window === 'undefined') return false; + const cap = (window as CapacitorWindow).Capacitor; + return typeof cap?.isNativePlatform === 'function' && cap.isNativePlatform(); +} + +export function platform(): 'web' | 'ios' | 'android' { + if (typeof window === 'undefined') return 'web'; + const cap = (window as CapacitorWindow).Capacitor; + const value = cap?.getPlatform?.() ?? 'web'; + return value === 'ios' || value === 'android' ? value : 'web'; +} diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts new file mode 100644 index 0000000..b7d4414 --- /dev/null +++ b/frontend/src/lib/notifications.ts @@ -0,0 +1,105 @@ +/** + * Native local-notification scheduling for interview reminders. + * + * On iOS and Android (Capacitor) this calls the LocalNotifications plugin via + * the runtime-injected `window.Capacitor.Plugins` bridge — no static import is + * needed, so the web bundle stays the same size and the SPA build never + * depends on @capacitor/local-notifications being installed. + * + * On the web (or on a native build without the plugin), `scheduleInterviewReminder` + * is a quiet no-op. + */ + +import { isNative } from './capacitor-auth'; + +interface LocalNotificationsPlugin { + requestPermissions(): Promise<{ display: string }>; + schedule(opts: { + notifications: Array<{ + id: number; + title: string; + body: string; + schedule?: { at: Date }; + }>; + }): Promise