From 378e0ce1a7469689c0b0b80b31271d374e56fffb Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 23 Mar 2026 14:17:21 +0200 Subject: [PATCH 01/44] feat: start nativewind v5 migration and token cleanup --- bun.lock | 130 +++++++++- metro.config.js | 6 +- nativewind-env.d.ts | 3 + package.json | 13 +- postcss.config.mjs | 5 + .../instructor/jobs/studios/[studioId].tsx | 10 +- .../instructor/profile/calendar-settings.tsx | 4 +- .../profile/identity-verification.tsx | 110 ++++----- .../instructor/profile/location.tsx | 8 +- .../instructor/profile/payments.tsx | 227 +++++++++--------- .../studio/profile/calendar-settings.tsx | 4 +- .../(studio-tabs)/studio/profile/index.tsx | 15 +- .../(studio-tabs)/studio/profile/payments.tsx | 4 +- src/app/(auth)/sign-in-screen.tsx | 11 +- src/app/_layout.tsx | 6 +- src/app/modal.tsx | 7 +- src/app/onboarding.tsx | 68 +++--- src/components/home/home-agenda-widget.tsx | 20 +- src/components/home/home-header-sheet.tsx | 10 +- src/components/home/home-shared.tsx | 32 +-- src/components/jobs/instructor-feed.tsx | 16 +- .../jobs/instructor/instructor-job-card.tsx | 18 +- .../jobs/studio/studio-jobs-list-parts.tsx | 15 +- src/components/loading-screen.tsx | 28 ++- .../map-tab/map-tab/map-web-command-panel.tsx | 77 +++--- .../map-tab/map-tab/map-web-header-panels.tsx | 49 ++-- src/components/maps/queue-map.native.tsx | 26 +- src/components/maps/queue-map.web.tsx | 127 +++++----- .../payments/payment-activity-list.tsx | 25 +- .../profile-tab/profile-mobile-hero.tsx | 18 +- src/components/ui/action-button.tsx | 8 +- src/components/ui/address-autocomplete.tsx | 27 ++- src/components/ui/kit/kit-button-group.tsx | 66 ++--- src/components/ui/kit/kit-chip.tsx | 11 +- .../ui/kit/kit-disclosure-button-group.tsx | 52 ++-- src/components/ui/kit/kit-floating-badge.tsx | 15 +- src/components/ui/kit/kit-list.tsx | 11 +- .../ui/kit/kit-segmented-toggle.tsx | 14 +- src/components/ui/kit/kit-status-badge.tsx | 17 +- src/components/ui/kit/kit-success-burst.tsx | 35 ++- src/components/ui/kit/kit-surface.tsx | 6 +- src/components/ui/kit/kit-text-field.tsx | 26 +- src/components/ui/native-search-field.tsx | 37 +-- src/constants/brand.ts | 15 +- src/global.css | 49 ++++ src/tw/image.tsx | 18 ++ src/tw/index.tsx | 5 + tsconfig.json | 14 +- 48 files changed, 870 insertions(+), 648 deletions(-) create mode 100644 nativewind-env.d.ts create mode 100644 postcss.config.mjs create mode 100644 src/global.css create mode 100644 src/tw/image.tsx create mode 100644 src/tw/index.tsx diff --git a/bun.lock b/bun.lock index e2b875c..1258557 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,8 @@ "@react-native-google-signin/google-signin": "16.1.2", "@react-navigation/native": "^7.1.33", "@shopify/flash-list": "2.0.2", + "@tailwindcss/postcss": "^4.2.2", + "clsx": "^2.1.1", "convex": "^1.34.0", "expo": "~55.0.8", "expo-auth-session": "~55.0.9", @@ -48,10 +50,12 @@ "expo-web-browser": "~55.0.10", "geojson": "^0.5.0", "i18next": "^25.9.0", + "nativewind": "5.0.0-preview.2", "react": "19.2.0", "react-dom": "19.2.0", "react-i18next": "^16.5.8", "react-native": "0.83.2", + "react-native-css": "0.0.0-nightly.5ce6396", "react-native-gesture-handler": "~2.30.0", "react-native-reanimated": "4.2.1", "react-native-safe-area-context": "~5.6.2", @@ -60,6 +64,8 @@ "react-native-web": "~0.21.2", "react-native-worklets": "0.7.2", "resend": "^6.9.4", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^4", }, "devDependencies": { "@biomejs/biome": "2.4.4", @@ -74,6 +80,8 @@ }, }, "packages": { + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + "@auth/core": ["@auth/core@0.37.0", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "@types/cookie": "0.6.0", "cookie": "0.7.1", "jose": "^5.9.3", "oauth4webapi": "^3.0.0", "preact": "10.11.3", "preact-render-to-string": "5.2.3" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^6.8.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-LybAgfFC5dta3Mu3al0UbnzMGVBpZRqLMvvXupQOfETtPNlL7rXgTO13EVRTCdvPqMQrVYjODUDvgVfQM1M3Qg=="], "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], @@ -640,6 +648,36 @@ "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], + "@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.2", "", { "os": "android", "cpu": "arm64" }, "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.2", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA=="], + + "@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "postcss": "^8.5.6", "tailwindcss": "4.2.2" } }, "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ=="], + "@turf/distance": ["@turf/distance@7.3.4", "", { "dependencies": { "@turf/helpers": "7.3.4", "@turf/invariant": "7.3.4", "@types/geojson": "^7946.0.10", "tslib": "^2.8.1" } }, "sha512-9drWgd46uHPPyzgrcRQLgSvdS/SjVlQ6ZIBoRQagS5P2kSjUbcOXHIMeOSPwfxwlKhEtobLyr+IiR2ns1TfF8w=="], "@turf/helpers": ["@turf/helpers@7.3.4", "", { "dependencies": { "@types/geojson": "^7946.0.10", "tslib": "^2.8.1" } }, "sha512-U/S5qyqgx3WTvg4twaH0WxF3EixoTCfDsmk98g1E3/5e2YKp7JKYZdz0vivsS5/UZLJeZDEElOSFH4pUgp+l7g=="], @@ -722,6 +760,8 @@ "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + "array-timsort": ["array-timsort@1.0.3", "", {}, "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ=="], + "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="], "astral-regex": ["astral-regex@1.0.0", "", {}, "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg=="], @@ -822,6 +862,8 @@ "clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], @@ -832,10 +874,14 @@ "colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="], + "colorjs.io": ["colorjs.io@0.6.0-alpha.1", "", {}, "sha512-c/h/8uAmPydQcriRdX8UTAFHj6SpSHFHBA8LvMikvYWAVApPTwg/pyOXNsGmaCBd6L/EeDlRHSNhTtnIFp/qsg=="], + "command-exists": ["command-exists@1.2.9", "", {}, "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w=="], "commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], + "comment-json": ["comment-json@4.6.2", "", { "dependencies": { "array-timsort": "^1.0.3", "esprima": "^4.0.1" } }, "sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w=="], + "compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="], "compression": ["compression@1.8.1", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w=="], @@ -912,6 +958,8 @@ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="], + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], @@ -1248,29 +1296,29 @@ "lighthouse-logger": ["lighthouse-logger@1.4.2", "", { "dependencies": { "debug": "^2.6.9", "marky": "^1.2.2" } }, "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g=="], - "lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], - "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], - "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="], + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], - "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="], + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], - "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="], + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], - "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="], + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], - "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="], + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], - "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="], + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], - "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="], + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], - "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="], + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], - "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="], + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], - "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="], + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], @@ -1290,6 +1338,8 @@ "lucia": ["lucia@3.2.2", "", { "dependencies": { "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0" } }, "sha512-P1FlFBGCMPMXu+EGdVD9W4Mjm0DqsusmKgO7Xc33mI5X1bklmsQb0hfzPhXomQr9waWIBDsiOjvr1e6BTaUqpA=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "makeerror": ["makeerror@1.0.12", "", { "dependencies": { "tmpl": "1.0.5" } }, "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg=="], "marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="], @@ -1360,6 +1410,8 @@ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "nativewind": ["nativewind@5.0.0-preview.2", "", { "dependencies": { "tailwindcss-safe-area": "^1.1.0" }, "peerDependencies": { "react-native-css": "^3.0.1", "tailwindcss": ">4.1.11" } }, "sha512-rTNrwFIwl/n2VH7KPvsZj/NdvKf+uGHF4NYtPamr5qG2eTYGT8B8VeyCPfYf/xUskpWOLJVqVEXaFO/vuIDEdw=="], + "negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="], "nocache": ["nocache@3.0.4", "", {}, "sha512-WDD0bdg9mbq6F4mRxEYcPWwfA1vxd0mrvKOyxI7Xj/atfRHVeutzuWByG//jfm4uPzp0y4Kj051EORCBSQMycw=="], @@ -1446,7 +1498,7 @@ "postal-mime": ["postal-mime@2.7.3", "", {}, "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw=="], - "postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="], + "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], @@ -1496,6 +1548,8 @@ "react-native": ["react-native@0.83.2", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.83.2", "@react-native/codegen": "0.83.2", "@react-native/community-cli-plugin": "0.83.2", "@react-native/gradle-plugin": "0.83.2", "@react-native/js-polyfills": "0.83.2", "@react-native/normalize-colors": "0.83.2", "@react-native/virtualized-lists": "0.83.2", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.32.0", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "hermes-compiler": "0.14.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.83.3", "metro-source-map": "^0.83.3", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.27.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.1.1", "react": "^19.2.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-ZDma3SLkRN2U2dg0/EZqxNBAx4of/oTnPjXAQi299VLq2gdnbZowGy9hzqv+O7sTA62g+lM1v+2FM5DUnJ/6hg=="], + "react-native-css": ["react-native-css@0.0.0-nightly.5ce6396", "", { "dependencies": { "@expo/metro-runtime": "~6.1.1", "babel-plugin-react-compiler": "^19.1.0-rc.2", "colorjs.io": "0.6.0-alpha.1", "comment-json": "^4.2.5", "debug": "^4.4.1" }, "peerDependencies": { "expo": "54.0.0-preview.6", "lightningcss": ">=1.27.0", "react": "19.1.0", "react-native": "0.81.0" } }, "sha512-jiSNSRpf5h2j1yS5vtOps8iZmZ2+GIM8YYZQrzcZsIaO8QDKdseZSGmxaeZ0cLy2tYCNKJH+1RyHdWMcvSouNg=="], + "react-native-gesture-handler": ["react-native-gesture-handler@2.30.0", "", { "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-5YsnKHGa0X9C8lb5oCnKm0fLUPM6CRduvUUw2Bav4RIj/C3HcFh4RIUnF8wgG6JQWCL1//gRx4v+LVWgcIQdGA=="], "react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.2.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q=="], @@ -1666,6 +1720,14 @@ "svix": ["svix@1.86.0", "", { "dependencies": { "standardwebhooks": "1.0.0", "uuid": "^10.0.0" } }, "sha512-/HTvXwjLJe1l/MsLXAO1ddCYxElJk4eNR4DzOjDOEmGrPN/3BtBE8perGwMAaJ2sT5T172VkBYzmHcjUfM1JRQ=="], + "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], + + "tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], + + "tailwindcss-safe-area": ["tailwindcss-safe-area@1.3.0", "", { "peerDependencies": { "tailwindcss": "^4.0.0" } }, "sha512-RoxnW1zAjBWC3XK+row7Qj5toRMRlKNN/p3FLXb6fTGKxDGWT6JP/mcNX1yf09xRficQ308hbwiedgniepSp1Q=="], + + "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + "terminal-link": ["terminal-link@2.1.1", "", { "dependencies": { "ansi-escapes": "^4.2.1", "supports-hyperlinks": "^2.0.0" } }, "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ=="], "terser": ["terser@5.46.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg=="], @@ -1820,6 +1882,10 @@ "@expo/fingerprint/arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], + "@expo/metro-config/lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="], + + "@expo/metro-config/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="], + "@expo/package-manager/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="], "@expo/prebuild-config/@expo/config-plugins": ["@expo/config-plugins@55.0.7", "", { "dependencies": { "@expo/config-types": "^55.0.5", "@expo/json-file": "~10.0.12", "@expo/plist": "^0.5.2", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", "getenv": "^2.0.0", "glob": "^13.0.0", "resolve-from": "^5.0.0", "semver": "^7.5.4", "slugify": "^1.6.6", "xcode": "^3.0.1", "xml2js": "0.6.0" } }, "sha512-XZUoDWrsHEkH3yasnDSJABM/UxP5a1ixzRwU/M+BToyn/f0nTrSJJe/Ay/FpxkI4JSNz2n0e06I23b2bleXKVA=="], @@ -1854,6 +1920,18 @@ "@react-native/dev-middleware/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@types/graceful-fs/@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="], "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], @@ -1952,6 +2030,10 @@ "react-native/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + "react-native-css/@expo/metro-runtime": ["@expo/metro-runtime@6.1.2", "", { "dependencies": { "anser": "^1.4.9", "pretty-format": "^29.7.0", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-dom": "*", "react-native": "*" }, "optionalPeers": ["react-dom"] }, "sha512-nvM+Qv45QH7pmYvP8JB1G8JpScrWND3KrMA6ZKe62cwwNiX/BjHU28Ear0v/4bQWXlOY0mv6B8CDIm8JxXde9g=="], + + "react-native-css/babel-plugin-react-compiler": ["babel-plugin-react-compiler@19.1.0-rc.1-rc-af1b7da-20250421", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-E3kaokBhWDLf7ZD8fuYjYn0ZJHYZ+3EHtAWCdX2hl4lpu1z9S/Xr99sxhx2bTCVB41oIesz9FtM8f4INsrZaOw=="], + "react-native-reanimated/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "react-native-web/@react-native/normalize-colors": ["@react-native/normalize-colors@0.74.89", "", {}, "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg=="], @@ -2004,6 +2086,28 @@ "@expo/cli/ora/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="], + "@expo/metro-config/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], + + "@expo/metro-config/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="], + + "@expo/metro-config/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="], + + "@expo/metro-config/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="], + + "@expo/metro-config/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="], + + "@expo/metro-config/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="], + + "@expo/metro-config/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="], + + "@expo/metro-config/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="], + + "@expo/metro-config/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="], + + "@expo/metro-config/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="], + + "@expo/metro-config/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="], + "@expo/package-manager/ora/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], "@expo/package-manager/ora/cli-cursor": ["cli-cursor@2.1.0", "", { "dependencies": { "restore-cursor": "^2.0.0" } }, "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw=="], diff --git a/metro.config.js b/metro.config.js index b073d5c..526d088 100644 --- a/metro.config.js +++ b/metro.config.js @@ -1,5 +1,6 @@ const path = require("node:path"); const { getDefaultConfig } = require("expo/metro-config"); +const { withNativewind } = require("nativewind/metro"); const config = getDefaultConfig(__dirname); @@ -59,4 +60,7 @@ config.transformer.getTransformOptions = async () => ({ }, }); -module.exports = config; +module.exports = withNativewind(config, { + inlineVariables: false, + globalClassNamePolyfill: false, +}); diff --git a/nativewind-env.d.ts b/nativewind-env.d.ts new file mode 100644 index 0000000..60b1c7b --- /dev/null +++ b/nativewind-env.d.ts @@ -0,0 +1,3 @@ +/// + +// NOTE: This file should not be edited and should be committed with your source code. It is generated by react-native-css. If you need to move or disable this file, please see the documentation. \ No newline at end of file diff --git a/package.json b/package.json index 855e672..b0b26fe 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,8 @@ "@react-native-google-signin/google-signin": "16.1.2", "@react-navigation/native": "^7.1.33", "@shopify/flash-list": "2.0.2", + "@tailwindcss/postcss": "^4.2.2", + "clsx": "^2.1.1", "convex": "^1.34.0", "expo": "~55.0.8", "expo-auth-session": "~55.0.9", @@ -85,10 +87,12 @@ "expo-web-browser": "~55.0.10", "geojson": "^0.5.0", "i18next": "^25.9.0", + "nativewind": "5.0.0-preview.2", "react": "19.2.0", "react-dom": "19.2.0", "react-i18next": "^16.5.8", "react-native": "0.83.2", + "react-native-css": "0.0.0-nightly.5ce6396", "react-native-gesture-handler": "~2.30.0", "react-native-reanimated": "4.2.1", "react-native-safe-area-context": "~5.6.2", @@ -96,7 +100,9 @@ "react-native-svg": "15.15.3", "react-native-web": "~0.21.2", "react-native-worklets": "0.7.2", - "resend": "^6.9.4" + "resend": "^6.9.4", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^4" }, "devDependencies": { "@biomejs/biome": "2.4.4", @@ -116,5 +122,8 @@ ] } }, - "private": true + "private": true, + "resolutions": { + "lightningcss": "1.30.1" + } } diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..c2ddf74 --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; diff --git a/src/app/(app)/(instructor-tabs)/instructor/jobs/studios/[studioId].tsx b/src/app/(app)/(instructor-tabs)/instructor/jobs/studios/[studioId].tsx index 055391d..daa9cc1 100644 --- a/src/app/(app)/(instructor-tabs)/instructor/jobs/studios/[studioId].tsx +++ b/src/app/(app)/(instructor-tabs)/instructor/jobs/studios/[studioId].tsx @@ -12,7 +12,7 @@ import { useTopSheetContentInsets } from "@/components/layout/use-top-sheet-cont import { LoadingScreen } from "@/components/loading-screen"; import { IconButton } from "@/components/ui/icon-button"; import { IconSymbol } from "@/components/ui/icon-symbol"; -import { BrandSpacing, BrandType } from "@/constants/brand"; +import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; import { toSportLabel } from "@/convex/constants"; @@ -88,7 +88,7 @@ export default function InstructorStudioProfileRoute() { if (!studioProfile || !pathname?.startsWith("/instructor/jobs/studios/")) { return null; } - const headerHeight = 284; + const headerHeight = BrandSpacing.iconContainer * 7 + BrandSpacing.componentPadding + BrandSpacing.xs; const availableHeight = Math.max(1, screenHeight - safeTop - 80); const collapsedStep = Math.max(0.24, Math.min(0.42, headerHeight / availableHeight)); @@ -99,8 +99,8 @@ export default function InstructorStudioProfileRoute() { height: headerHeight, justifyContent: "space-between", overflow: "hidden", - borderBottomLeftRadius: 28, - borderBottomRightRadius: 28, + borderBottomLeftRadius: BrandRadius.cardSubtle + 10, + borderBottomRightRadius: BrandRadius.cardSubtle + 10, borderCurve: "continuous", backgroundColor: palette.primary as string, }} @@ -146,7 +146,7 @@ export default function InstructorStudioProfileRoute() { style={{ paddingHorizontal: BrandSpacing.xl, paddingBottom: BrandSpacing.xxl, - gap: 8, + gap: BrandSpacing.sm, }} > @@ -207,9 +207,9 @@ function LoaderDot({ delay, color }: { delay: number; color: string }) { @@ -287,11 +287,11 @@ function VerificationResolvingState({ label }: { label: string }) { style={[ { position: "absolute", - top: 28, - left: 34, - width: 16, - height: 16, - borderRadius: 999, + top: BrandSpacing.xl, + left: BrandSpacing.lg, + width: BrandSpacing.sm, + height: BrandSpacing.sm, + borderRadius: BrandRadius.pill, backgroundColor: palette.primarySubtle as string, }, bubbleLeftStyle, @@ -301,11 +301,11 @@ function VerificationResolvingState({ label }: { label: string }) { style={[ { position: "absolute", - right: 40, - bottom: 34, - width: 12, - height: 12, - borderRadius: 999, + right: BrandSpacing.xl, + bottom: BrandSpacing.lg, + width: BrandSpacing.sm, + height: BrandSpacing.sm, + borderRadius: BrandRadius.pill, backgroundColor: palette.primarySubtle as string, }, bubbleRightStyle, @@ -315,12 +315,12 @@ function VerificationResolvingState({ label }: { label: string }) { - + @@ -365,7 +365,7 @@ function VerificationResolvingState({ label }: { label: string }) { {t("profile.identityVerification.resolvingTitle")} @@ -422,9 +422,9 @@ export default function IdentityVerificationScreen() { const lastEventAtLabel = formatDateTime(diditVerification?.lastEventAt); const isInProgressState = status === "in_progress" || status === "pending" || status === "in_review"; - const diditStatusBackground = resolvedScheme === "dark" ? "#16243B" : "#EEF5FF"; - const diditSectionBackground = resolvedScheme === "dark" ? "#141C2A" : "#F6F7FB"; - const diditPressedBlue = resolvedScheme === "dark" ? "#4C96FF" : "#256FE0"; + const diditStatusBackground = resolvedScheme === "dark" ? palette.accentDark : palette.accentLight; + const diditSectionBackground = resolvedScheme === "dark" ? palette.accentRowBgDark : palette.accentRowBgLight; + const diditPressedBlue = palette.didit.accent; const openExternalUrl = useCallback( (url: string) => { void ExpoLinking.openURL(url).catch(() => { @@ -457,16 +457,16 @@ export default function IdentityVerificationScreen() { { void refreshVerificationStatus(); }} - icon={} + icon={} /> ), - [busy, isRefreshing, refreshVerificationStatus, t], + [busy, isRefreshing, palette.onPrimary, palette.primarySubtle, refreshVerificationStatus, t], ); useProfileSubpageSheet({ title: t("profile.navigation.identityVerification"), @@ -679,7 +679,7 @@ export default function IdentityVerificationScreen() { routeKey="instructor/profile/identity-verification" style={{ flex: 1, backgroundColor: palette.appBg }} contentContainerStyle={{ - gap: 22, + gap: BrandSpacing.xl, }} topSpacing={16} bottomSpacing={44} @@ -698,15 +698,15 @@ export default function IdentityVerificationScreen() { - + {t("profile.identityVerification.eyebrow")} @@ -715,16 +715,16 @@ export default function IdentityVerificationScreen() { flexDirection: "row", alignItems: "flex-start", justifyContent: "space-between", - gap: 12, + gap: BrandSpacing.sm, }} > - - + + {getStatusHeadline(status, t)} {getStatusBody(status, t)} @@ -736,7 +736,7 @@ export default function IdentityVerificationScreen() { {legalName ? ( @@ -747,7 +747,7 @@ export default function IdentityVerificationScreen() { ) : null} {verifiedAtLabel || lastEventAtLabel ? ( - + {verifiedAtLabel ? ( {t("profile.identityVerification.verifiedAt", { @@ -773,11 +773,11 @@ export default function IdentityVerificationScreen() { }} disabled={busy || isRefreshing} style={({ pressed }) => ({ - borderRadius: 20, + borderRadius: BrandRadius.button, borderCurve: "continuous", alignItems: "center", - paddingVertical: 17, - paddingHorizontal: 18, + paddingVertical: BrandSpacing.md, + paddingHorizontal: BrandSpacing.md, borderWidth: 1, borderColor: busy || isRefreshing ? (palette.borderStrong as string) : palette.didit.accent, backgroundColor: busy || isRefreshing @@ -814,22 +814,22 @@ export default function IdentityVerificationScreen() { {t("profile.identityVerification.whyTitle")} {t("profile.identityVerification.whyIntro")} - + @@ -456,10 +456,10 @@ export default function ProfilePaymentsScreen() { @@ -472,10 +472,10 @@ export default function ProfilePaymentsScreen() { alignItems: "center", justifyContent: "center", borderWidth: 3, - borderColor: "rgba(255,255,255,0.3)", + borderColor: palette.primarySubtle, }} > - + {t("profile.payments.verifyToConnectBankTitle")} @@ -490,10 +490,10 @@ export default function ProfilePaymentsScreen() { style={{ flexDirection: "row", alignItems: "center", - gap: 6, - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: 999, + gap: BrandSpacing.xs + 2, + paddingHorizontal: BrandSpacing.sm + 4, + paddingVertical: BrandSpacing.xs + 2, + borderRadius: BrandRadius.pill, backgroundColor: palette.didit.accentSubtle, }} > @@ -510,16 +510,16 @@ export default function ProfilePaymentsScreen() { }} style={({ pressed }) => ({ width: "100%", - paddingVertical: 16, - paddingHorizontal: 18, - borderRadius: 20, + paddingVertical: BrandSpacing.md, + paddingHorizontal: BrandSpacing.md + 2, + borderRadius: BrandRadius.button, borderCurve: "continuous", alignItems: "center", backgroundColor: palette.didit.accent, opacity: pressed ? 0.85 : 1, })} > - + {t("profile.payments.verifyToConnectBankCta")} @@ -559,9 +559,9 @@ export default function ProfilePaymentsScreen() { @@ -612,9 +612,9 @@ export default function ProfilePaymentsScreen() { onPress={() => router.push(INSTRUCTOR_IDENTITY_VERIFICATION_ROUTE as Href)} style={({ pressed }) => ({ alignSelf: "flex-start", - paddingHorizontal: 14, - paddingVertical: 8, - borderRadius: 999, + paddingHorizontal: BrandSpacing.sm + 2, + paddingVertical: BrandSpacing.sm, + borderRadius: BrandRadius.pill, borderCurve: "continuous", backgroundColor: pressed ? palette.surfaceAlt : palette.primarySubtle, borderWidth: 1, @@ -641,9 +641,9 @@ export default function ProfilePaymentsScreen() { @@ -652,14 +652,15 @@ export default function ProfilePaymentsScreen() { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start", - gap: 12, + gap: BrandSpacing.sm + 4, }} > - + {payoutSummary?.currency ?? "ILS"} @@ -707,27 +709,29 @@ export default function ProfilePaymentsScreen() { ({ - flex: 1, - backgroundColor: + style={({ pressed }) => { + const isDisabled = !isManualPayoutMode || !isIdentityVerified || !payoutSummary?.hasVerifiedDestination || - (payoutSummary?.availableAmountAgorot ?? 0) <= 0 - ? "rgba(255,255,255,0.1)" - : "rgba(255,255,255,0.25)", - borderRadius: 20, - minHeight: 54, - padding: 14, - alignItems: "center", - borderCurve: "continuous", - opacity: withdrawBusy ? 0.5 : 1, - flexDirection: "row", - justifyContent: "center", - gap: 8, - overflow: "hidden", - transform: [{ scale: pressed ? 0.985 : 1 }], - })} + (payoutSummary?.availableAmountAgorot ?? 0) <= 0; + const bgOpacity = isDisabled ? 0.1 : 0.25; + return { + flex: 1, + backgroundColor: palette.onPrimary, + opacity: withdrawBusy ? 0.5 : bgOpacity, + borderRadius: BrandRadius.button, + minHeight: 54, + padding: BrandSpacing.sm + 6, + alignItems: "center", + borderCurve: "continuous", + flexDirection: "row", + justifyContent: "center", + gap: BrandSpacing.sm, + overflow: "hidden", + transform: [{ scale: pressed ? 0.985 : 1 }], + }; + }} onPress={() => { confirmWithdrawToBank(); }} @@ -739,8 +743,8 @@ export default function ProfilePaymentsScreen() { (payoutSummary?.availableAmountAgorot ?? 0) <= 0 } > - - + + {t("profile.payments.withdraw")} @@ -755,25 +759,28 @@ export default function ProfilePaymentsScreen() { style={({ pressed }) => ({ flex: 1, backgroundColor: payoutSummary?.hasVerifiedDestination + ? palette.onPrimary + : palette.text, + opacity: payoutSummary?.hasVerifiedDestination ? pressed - ? "rgba(255,255,255,0.2)" - : "rgba(255,255,255,0.14)" + ? 0.2 + : 0.14 : pressed - ? "rgba(0,0,0,0.88)" - : "#000", - borderRadius: 20, + ? 0.88 + : 1, + borderRadius: BrandRadius.button, minHeight: 54, - padding: 14, + padding: BrandSpacing.sm + 6, alignItems: "center", borderCurve: "continuous", flexDirection: "row", justifyContent: "center", - gap: 8, + gap: BrandSpacing.sm, overflow: "hidden", borderWidth: 1, borderColor: payoutSummary?.hasVerifiedDestination - ? "rgba(255,255,255,0.18)" - : "rgba(255,255,255,0.22)", + ? palette.onPrimary + : palette.border, transform: [{ scale: pressed ? 0.985 : 1 }], })} onPress={() => { @@ -785,8 +792,8 @@ export default function ProfilePaymentsScreen() { }} disabled={onboardingBusy} > - - + + {payoutSummary?.hasVerifiedDestination ? t("profile.payments.manageBank") : t("profile.payments.connectBank")} @@ -796,8 +803,8 @@ export default function ProfilePaymentsScreen() { {/* Stats Row - Merged into Hero Card */} - - + + - + {t("profile.payments.pending")} - + {formatAgorotCurrency( payoutSummary?.pendingAmountAgorot ?? 0, locale, @@ -817,7 +824,7 @@ export default function ProfilePaymentsScreen() { )} - + - + {t("profile.payments.paid")} - + {formatAgorotCurrency( payoutSummary?.paidAmountAgorot ?? 0, locale, @@ -875,20 +882,20 @@ export default function ProfilePaymentsScreen() { {effectivePreferenceMode === "scheduled_date" ? ( - + setShowSchedulePicker((value) => !value)} style={({ pressed }) => ({ - borderRadius: 16, + borderRadius: BrandRadius.buttonSubtle, borderCurve: "continuous", borderWidth: 1, borderColor: palette.border as string, backgroundColor: pressed ? palette.surface : palette.appBg, - paddingHorizontal: 16, - paddingVertical: 12, - gap: 4, + paddingHorizontal: BrandSpacing.lg, + paddingVertical: BrandSpacing.sm + 4, + gap: BrandSpacing.xs, })} > @@ -900,13 +907,13 @@ export default function ProfilePaymentsScreen() { {showSchedulePicker ? ( setShowSchedulePicker(false)} style={({ pressed }) => ({ alignSelf: "flex-start", - paddingHorizontal: 14, - paddingVertical: 10, - borderRadius: 999, + paddingHorizontal: BrandSpacing.sm + 6, + paddingVertical: BrandSpacing.sm + 2, + borderRadius: BrandRadius.pill, borderCurve: "continuous", backgroundColor: pressed ? palette.surface : palette.primarySubtle, })} @@ -946,7 +953,7 @@ export default function ProfilePaymentsScreen() { ) : null} - + - + {preferenceBusy ? t("profile.payments.preferenceSaving") : t("profile.payments.preferenceSaveSchedule")} @@ -1012,7 +1019,7 @@ export default function ProfilePaymentsScreen() { {selectedPaymentId ? ( - + setSelectedPaymentId(null)} style={({ pressed }) => ({ backgroundColor: palette.surfaceAlt, - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: 999, + paddingHorizontal: BrandSpacing.sm + 4, + paddingVertical: BrandSpacing.xs + 2, + borderRadius: BrandRadius.pill, opacity: pressed ? 0.84 : 1, })} > @@ -1042,8 +1049,8 @@ export default function ProfilePaymentsScreen() { @@ -1055,8 +1062,8 @@ export default function ProfilePaymentsScreen() { @@ -1068,19 +1075,19 @@ export default function ProfilePaymentsScreen() { - + {formatAgorotCurrency( role === "studio" ? selectedPaymentDetail.payment.studioChargeAmountAgorot @@ -1113,7 +1120,7 @@ export default function ProfilePaymentsScreen() { {formatDateTime(selectedPaymentDetail.payment.createdAt, locale)} - + diff --git a/src/app/(app)/(studio-tabs)/studio/profile/calendar-settings.tsx b/src/app/(app)/(studio-tabs)/studio/profile/calendar-settings.tsx index 46fba72..cb11300 100644 --- a/src/app/(app)/(studio-tabs)/studio/profile/calendar-settings.tsx +++ b/src/app/(app)/(studio-tabs)/studio/profile/calendar-settings.tsx @@ -547,7 +547,7 @@ const styles = StyleSheet.create({ }, content: { paddingHorizontal: BrandSpacing.lg, - paddingBottom: 112, + paddingBottom: BrandSpacing.xxl + BrandSpacing.xxl + BrandSpacing.xxl + BrandSpacing.md, gap: BrandSpacing.lg, }, connectionList: { @@ -564,7 +564,7 @@ const styles = StyleSheet.create({ ...BrandType.body, }, actionStack: { - gap: 10, + gap: BrandSpacing.sm + 2, }, footerAction: { position: "absolute", diff --git a/src/app/(app)/(studio-tabs)/studio/profile/index.tsx b/src/app/(app)/(studio-tabs)/studio/profile/index.tsx index 7a80502..8e63d2f 100644 --- a/src/app/(app)/(studio-tabs)/studio/profile/index.tsx +++ b/src/app/(app)/(studio-tabs)/studio/profile/index.tsx @@ -35,6 +35,7 @@ import { useAppLanguage } from "@/hooks/use-app-language"; import { useBrand } from "@/hooks/use-brand"; import { useLayoutBreakpoint } from "@/hooks/use-layout-breakpoint"; import { useThemePreference } from "@/hooks/use-theme-preference"; +import { BrandSpacing } from "@/constants/brand"; import { EXPIRY_OVERRIDE_PRESETS } from "@/lib/jobs-utils"; import { omitUndefined } from "@/lib/omit-undefined"; import { buildRoleTabRoute, ROLE_TAB_ROUTE_NAMES } from "@/navigation/role-routes"; @@ -102,7 +103,7 @@ export default function StudioProfileScreen() { const [autoAcceptDefault, setAutoAcceptDefault] = useState(false); const [isSavingAutoAcceptDefault, setIsSavingAutoAcceptDefault] = useState(false); const [autoExpireMinutesBefore, setAutoExpireMinutesBefore] = useState(undefined); - const [isSavingAutoExpireMinutes, setIsSavingAutoExpireMinutes] = useState(false); + const [, setIsSavingAutoExpireMinutes] = useState(false); useEffect(() => { if (studioSettings) { @@ -639,10 +640,10 @@ export default function StudioProfileScreen() { routeKey="studio/profile" style={styles.screen} contentContainerStyle={{ - gap: 18, + gap: BrandSpacing.lg + 2, }} - topSpacing={18} - bottomSpacing={32} + topSpacing={BrandSpacing.lg + 2} + bottomSpacing={BrandSpacing.xxl} > diff --git a/src/app/(auth)/sign-in-screen.tsx b/src/app/(auth)/sign-in-screen.tsx index 77fb6bf..be1b995 100644 --- a/src/app/(auth)/sign-in-screen.tsx +++ b/src/app/(auth)/sign-in-screen.tsx @@ -20,7 +20,6 @@ import { useBrand } from "@/hooks/use-brand"; type Step = "email" | "code"; const OTP_LENGTH = 6; -const GOOGLE_RED = "#EA4335"; function getErrorMessage(error: unknown, fallback: string) { if (error instanceof Error && error.message) return error.message; @@ -332,7 +331,7 @@ export default function SignInScreen() { } + icon={} onPress={() => { void handleOAuth("google"); }} @@ -405,7 +404,7 @@ export default function SignInScreen() { )} - + {infoMessage ? ( ) : null} @@ -440,7 +439,7 @@ const styles = StyleSheet.create({ }, actionRow: { flexDirection: "row", - gap: 10, + gap: BrandSpacing.sm + 2, }, rowAction: { flex: 1, @@ -449,7 +448,7 @@ const styles = StyleSheet.create({ flexDirection: "row", alignItems: "center", gap: BrandSpacing.sm, - paddingVertical: 6, + paddingVertical: BrandSpacing.xs + 2, }, dividerLine: { flex: 1, @@ -459,6 +458,6 @@ const styles = StyleSheet.create({ flexDirection: "row", alignItems: "stretch", justifyContent: "center", - gap: 14, + gap: BrandSpacing.componentPadding, }, }); diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 8cdabd4..88068ab 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -1,3 +1,4 @@ +import "@/global.css"; import { ConvexAuthProvider } from "@convex-dev/auth/react"; import MaterialIcons from "@expo/vector-icons/MaterialIcons"; import { BarlowCondensed_800ExtraBold } from "@expo-google-fonts/barlow-condensed"; @@ -7,6 +8,7 @@ import { Rubik_600SemiBold, Rubik_700Bold, } from "@expo-google-fonts/rubik"; +import { BrandSpacing } from "@/constants/brand"; import { DarkTheme, DefaultTheme, @@ -203,7 +205,7 @@ const styles = StyleSheet.create({ flex: 1, alignItems: "center", justifyContent: "center", - gap: 12, - paddingHorizontal: 24, + gap: BrandSpacing.md, + paddingHorizontal: BrandSpacing.xl, }, }); diff --git a/src/app/modal.tsx b/src/app/modal.tsx index 443e26c..87f1031 100644 --- a/src/app/modal.tsx +++ b/src/app/modal.tsx @@ -2,6 +2,7 @@ import { Link } from "expo-router"; import { useTranslation } from "react-i18next"; import { StyleSheet } from "react-native"; +import { BrandSpacing } from "@/constants/brand"; import { ThemedText } from "@/components/themed-text"; import { ThemedView } from "@/components/themed-view"; @@ -23,10 +24,10 @@ const styles = StyleSheet.create({ flex: 1, alignItems: "center", justifyContent: "center", - padding: 20, + padding: BrandSpacing.lg, }, link: { - marginTop: 15, - paddingVertical: 15, + marginTop: BrandSpacing.componentPadding, + paddingVertical: BrandSpacing.componentPadding, }, }); diff --git a/src/app/onboarding.tsx b/src/app/onboarding.tsx index 8b181ac..29b8f8b 100644 --- a/src/app/onboarding.tsx +++ b/src/app/onboarding.tsx @@ -27,7 +27,7 @@ import { ChoicePill } from "@/components/ui/choice-pill"; import { IconButton } from "@/components/ui/icon-button"; import { KitChip, KitTextField } from "@/components/ui/kit"; import { SheetHeaderBlock } from "@/components/ui/sheet-header-block"; -import { BrandSpacing } from "@/constants/brand"; +import { BrandRadius, BrandSpacing } from "@/constants/brand"; import { ZONE_OPTIONS } from "@/constants/zones"; import { api } from "@/convex/_generated/api"; import { SPORT_TYPES } from "@/convex/constants"; @@ -1412,9 +1412,9 @@ const styles = StyleSheet.create({ flex: 1, }, content: { - paddingHorizontal: 16, - paddingVertical: 20, - gap: 12, + paddingHorizontal: BrandSpacing.md, + paddingVertical: BrandSpacing.lg, + gap: BrandSpacing.sm, }, stageViewport: { minHeight: 1, @@ -1422,16 +1422,16 @@ const styles = StyleSheet.create({ detailsLoadingStage: { minHeight: 260, justifyContent: "center", - gap: 12, - paddingVertical: 12, + gap: BrandSpacing.sm, + paddingVertical: BrandSpacing.sm, }, detailsLoadingHeader: { - gap: 4, + gap: BrandSpacing.xs, }, detailsLoadingRow: { flexDirection: "row", alignItems: "center", - gap: 10, + gap: BrandSpacing.sm, }, contentGrow: { flexGrow: 1, @@ -1444,12 +1444,12 @@ const styles = StyleSheet.create({ paddingBottom: BrandSpacing.xl, }, roleStageHeader: { - gap: 6, + gap: BrandSpacing.xs, }, roleGrid: { flexDirection: "row", alignItems: "stretch", - gap: 12, + gap: BrandSpacing.sm, marginTop: BrandSpacing.md, }, roleOption: { @@ -1468,91 +1468,91 @@ const styles = StyleSheet.create({ }, navRowSplit: { flexDirection: "row", - gap: 12, + gap: BrandSpacing.sm, }, navAction: { flex: 1, }, stepTwoWrap: { - gap: 14, + gap: BrandSpacing.md, }, stepTwoDesktop: { flexDirection: "row", alignItems: "stretch", }, formPanel: { - gap: 12, + gap: BrandSpacing.sm, flex: 1, minWidth: 320, - borderRadius: 24, + borderRadius: BrandRadius.card, borderCurve: "continuous", - padding: 16, + padding: BrandSpacing.md, }, mapPanel: { flex: 1, minWidth: 320, minHeight: 360, - gap: 10, - borderRadius: 24, + gap: BrandSpacing.sm, + borderRadius: BrandRadius.card, borderCurve: "continuous", - padding: 16, + padding: BrandSpacing.md, }, mapHeader: { - gap: 4, + gap: BrandSpacing.xs, }, mapHeaderRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", - gap: 8, + gap: BrandSpacing.sm, }, mapWrap: { flex: 1, - minHeight: 300, + minHeight: BrandSpacing.mapMinHeight, }, mapLoadingState: { flex: 1, - minHeight: 300, + minHeight: BrandSpacing.mapMinHeight, alignItems: "center", justifyContent: "center", - gap: 10, - borderRadius: 20, + gap: BrandSpacing.sm, + borderRadius: BrandRadius.button, borderCurve: "continuous", }, chipGrid: { flexDirection: "row", flexWrap: "wrap", - gap: 8, + gap: BrandSpacing.sm, }, sectionBlock: { - gap: 8, + gap: BrandSpacing.sm, }, inlineActions: { flexDirection: "row", - gap: 8, + gap: BrandSpacing.sm, }, locationPreviewRow: { minHeight: 44, justifyContent: "center", }, multilineInput: { - minHeight: 96, + minHeight: BrandSpacing.multilineInputMinHeight, textAlignVertical: "top", }, verifyActions: { - gap: 10, + gap: BrandSpacing.sm, }, verifyStage: { - gap: 12, + gap: BrandSpacing.sm, borderWidth: 1.5, - borderRadius: 24, + borderRadius: BrandRadius.card, borderCurve: "continuous", - padding: 16, + padding: BrandSpacing.md, }, errorBanner: { borderWidth: 1.5, - borderRadius: 20, + borderRadius: BrandRadius.button, borderCurve: "continuous", - padding: 14, + padding: BrandSpacing.md, }, }); diff --git a/src/components/home/home-agenda-widget.tsx b/src/components/home/home-agenda-widget.tsx index c88f83e..ac27b11 100644 --- a/src/components/home/home-agenda-widget.tsx +++ b/src/components/home/home-agenda-widget.tsx @@ -5,9 +5,11 @@ import Animated, { FadeInUp } from "react-native-reanimated"; import { HomeSectionHeading, HomeSurface } from "@/components/home/home-dashboard-layout"; import { getRelativeTimeLabel } from "@/components/home/home-shared"; import type { BrandPalette } from "@/constants/brand"; -import { BrandSpacing, BrandType } from "@/constants/brand"; +import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; import { toSportLabel } from "@/convex/constants"; +const TIME_WIDTH = 56; + type AgendaItem = { id: string; sport: string; @@ -138,23 +140,21 @@ export function HomeAgendaWidget({ style={{ flexDirection: "row", alignItems: "center", - paddingVertical: 10, - gap: 12, + paddingVertical: BrandSpacing.sm + 2, + gap: BrandSpacing.md, borderBottomWidth: index < visibleItems.length - 1 ? 1 : 0, borderBottomColor: (palette.border as string) ?? "rgba(0,0,0,0.06)", }} > {isToday ? relativeTime : formatGroupDate(item.startTime, locale)} @@ -176,7 +174,6 @@ export function HomeAgendaWidget({ diff --git a/src/components/home/home-header-sheet.tsx b/src/components/home/home-header-sheet.tsx index 9d9b389..95f0aca 100644 --- a/src/components/home/home-header-sheet.tsx +++ b/src/components/home/home-header-sheet.tsx @@ -4,10 +4,12 @@ import { Pressable, Text, View } from "react-native"; import { KitFloatingBadge } from "@/components/ui/kit/kit-floating-badge"; import { ProfileAvatar } from "@/components/ui/profile-avatar"; import type { BrandPalette } from "@/constants/brand"; -import { BrandSpacing, BrandType } from "@/constants/brand"; +import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; const SHEET_EXPANDED_CONTENT_HEIGHT = 84; const SHEET_CONTENT_GAP = BrandSpacing.sm; +const AVATAR_SIZE = 68; +const BADGE_SIZE = 22; export function getHomeHeaderExpandedHeight(safeTop: number) { return safeTop + SHEET_EXPANDED_CONTENT_HEIGHT; @@ -85,19 +87,19 @@ export const HomeHeaderSheet = memo(function HomeHeaderSheet({ accessibilityLabel={onPressAvatar ? t("home.actions.profileTitle") : undefined} onPress={onPressAvatar} disabled={!onPressAvatar} - style={{ borderRadius: 24 }} + style={{ borderRadius: BrandRadius.card }} > diff --git a/src/components/home/home-shared.tsx b/src/components/home/home-shared.tsx index da0d450..0f149aa 100644 --- a/src/components/home/home-shared.tsx +++ b/src/components/home/home-shared.tsx @@ -3,11 +3,11 @@ import { Text, View } from "react-native"; import { ThemedText } from "@/components/themed-text"; import { IconSymbol } from "@/components/ui/icon-symbol"; import type { BrandPalette } from "@/constants/brand"; -import { BrandRadius, BrandType } from "@/constants/brand"; +import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; import type { JobStatus } from "@/lib/status-tokens"; import { getJobStatusTokens } from "@/lib/status-tokens"; -export const CONTENT_VERTICAL_PADDING = 20; +export const CONTENT_VERTICAL_PADDING = BrandSpacing.lg; type IntlWithOptionalRelativeTimeFormat = typeof Intl & { RelativeTimeFormat?: typeof Intl.RelativeTimeFormat; @@ -75,10 +75,10 @@ export function StatusPill({ label, status, palette }: StatusPillProps) { return ( @@ -104,18 +104,18 @@ export function DotStatusPill({ backgroundColor, color, label }: DotStatusPillPr style={{ flexDirection: "row", alignItems: "center", - gap: 6, - borderRadius: 999, + gap: BrandSpacing.sm, + borderRadius: BrandRadius.pill, backgroundColor, - paddingHorizontal: 10, - paddingVertical: 6, + paddingHorizontal: BrandSpacing.sm, + paddingVertical: BrandSpacing.xs + 2, }} > @@ -144,7 +144,7 @@ type MetricCellProps = { export function MetricCell({ align = "flex-start", icon, label, value, palette }: MetricCellProps) { return ( - + {icon ? : null} - + {icon ? : null} @@ -171,11 +177,11 @@ export function InstructorFeed() { /> } size="sm" - railColor="rgba(52, 32, 96, 0.82)" - selectedColor="rgba(255, 255, 255, 0.2)" - labelColor="rgba(255, 255, 255, 0.76)" + railColor={RAIL_COLOR} + selectedColor={SELECTED_COLOR} + labelColor={LABEL_COLOR} selectedLabelColor={String(palette.onPrimary)} - dividerColor="rgba(255, 255, 255, 0.12)" + dividerColor={DIVIDER_COLOR} /> diff --git a/src/components/jobs/instructor/instructor-job-card.tsx b/src/components/jobs/instructor/instructor-job-card.tsx index 6bd0df6..0cc4918 100644 --- a/src/components/jobs/instructor/instructor-job-card.tsx +++ b/src/components/jobs/instructor/instructor-job-card.tsx @@ -5,7 +5,9 @@ import { ActionButton } from "@/components/ui/action-button"; import { IconSymbol } from "@/components/ui/icon-symbol"; import { KitSurface } from "@/components/ui/kit"; import type { BrandPalette } from "@/constants/brand"; -import { BrandRadius, BrandType } from "@/constants/brand"; +import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; + +const IMAGE_PANEL_WIDTH_PERCENT = "44%"; import { getZoneLabel } from "@/constants/zones"; import type { Id } from "@/convex/_generated/dataModel"; import { toSportLabel } from "@/convex/constants"; @@ -77,10 +79,10 @@ function StudioImagePanel({ top: 0, right: 0, bottom: 0, - width: "44%", + width: IMAGE_PANEL_WIDTH_PERCENT, overflow: "hidden", - borderTopLeftRadius: 28, - borderBottomLeftRadius: 28, + borderTopLeftRadius: BrandRadius.card, + borderBottomLeftRadius: BrandRadius.card, borderCurve: "continuous", backgroundColor: palette.surfaceElevated as string, }} @@ -135,11 +137,11 @@ function JobExpiryPill({ style={{ flexDirection: "row", alignItems: "center", - gap: 6, - borderRadius: 999, + gap: BrandSpacing.sm, + borderRadius: BrandRadius.pill, backgroundColor, - paddingHorizontal: 10, - paddingVertical: 6, + paddingHorizontal: BrandSpacing.sm, + paddingVertical: BrandSpacing.xs + 1, }} > diff --git a/src/components/jobs/studio/studio-jobs-list-parts.tsx b/src/components/jobs/studio/studio-jobs-list-parts.tsx index 6f24236..5d39841 100644 --- a/src/components/jobs/studio/studio-jobs-list-parts.tsx +++ b/src/components/jobs/studio/studio-jobs-list-parts.tsx @@ -7,6 +7,9 @@ import { ActionButton } from "@/components/ui/action-button"; import { IconSymbol } from "@/components/ui/icon-symbol"; import { KitSurface } from "@/components/ui/kit"; import { type BrandPalette, BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; + +const AVATAR_SIZE = 42; +const AVATAR_RADIUS = 16; import { getZoneLabel } from "@/constants/zones"; import type { Id } from "@/convex/_generated/dataModel"; import { toSportLabel } from "@/convex/constants"; @@ -112,11 +115,11 @@ function MetaPill({ style={{ flexDirection: "row", alignItems: "center", - gap: 6, + gap: BrandSpacing.sm, borderRadius: BrandRadius.pill, backgroundColor, - paddingHorizontal: 10, - paddingVertical: 7, + paddingHorizontal: BrandSpacing.sm, + paddingVertical: BrandSpacing.xs + 1, }} > @@ -234,11 +237,11 @@ export const ApplicationRow = memo(function ApplicationRow({ > - + - + @@ -114,11 +121,11 @@ export function MapWebCommandPanel({ @@ -168,11 +175,11 @@ export function MapWebCommandPanel({ {saveError ? ( @@ -297,8 +304,8 @@ export function MapWebCommandPanel({ style={({ pressed }) => ({ alignItems: "center", justifyContent: "center", - paddingHorizontal: 14, - paddingVertical: 14, + paddingHorizontal: BrandSpacing.md + 2, + paddingVertical: BrandSpacing.md + 2, backgroundColor: focusZoneId === zone.id ? "rgba(255,255,255,0.14)" @@ -345,13 +352,13 @@ export function MapWebCommandPanel({ accessibilityRole="button" onPress={() => onToggleZone(zone.id)} style={({ pressed }) => ({ - borderRadius: 20, + borderRadius: ZONE_SELECT_RADIUS, borderCurve: "continuous", backgroundColor: selected ? (palette.primary as string) : (palette.surface as string), - paddingHorizontal: 14, - paddingVertical: 12, + paddingHorizontal: BrandSpacing.md + 2, + paddingVertical: BrandSpacing.md, opacity: pressed ? 0.92 : 1, })} > diff --git a/src/components/map-tab/map-tab/map-web-header-panels.tsx b/src/components/map-tab/map-tab/map-web-header-panels.tsx index 3fb4cad..c2cd0fa 100644 --- a/src/components/map-tab/map-tab/map-web-header-panels.tsx +++ b/src/components/map-tab/map-tab/map-web-header-panels.tsx @@ -2,7 +2,10 @@ import type { TFunction } from "i18next"; import { Text, View } from "react-native"; import { ActionButton } from "@/components/ui/action-button"; -import { type BrandPalette, BrandType } from "@/constants/brand"; +import { type BrandPalette, BrandSpacing, BrandType } from "@/constants/brand"; + +const PANEL_RADIUS = 30; +const INNER_RADIUS = 18; type MapWebHeaderPanelsProps = { t: TFunction; @@ -28,16 +31,16 @@ export function MapWebHeaderPanels({ onReset, }: MapWebHeaderPanelsProps) { return ( - + @@ -74,12 +74,12 @@ export function MapWebHeaderPanels({ @@ -115,15 +112,15 @@ export function MapWebHeaderPanels({ > {hasChanges ? t("mapTab.web.statePending") : t("mapTab.web.stateReady")} - + @@ -151,11 +148,11 @@ export function MapWebHeaderPanels({ @@ -183,7 +180,7 @@ export function MapWebHeaderPanels({ - + [ styles.gps, { - width: 58, - height: 58, + width: GPS_BUTTON_SIZE, + height: GPS_BUTTON_SIZE, alignItems: "center", justifyContent: "center", borderWidth: 1.2, @@ -388,7 +394,7 @@ export const QueueMap = memo(function QueueMap({ }, ]} > - + ) : null} @@ -409,7 +415,7 @@ export const QueueMap = memo(function QueueMap({ }, ]} > - + ) : null} @@ -428,9 +434,9 @@ const styles = StyleSheet.create({ position: "absolute", left: BrandSpacing.lg, bottom: BrandSpacing.lg, - width: 34, - height: 34, - borderRadius: 17, + width: ATTRIBUTION_SIZE, + height: ATTRIBUTION_SIZE, + borderRadius: BrandSpacing.iconContainer / 2, alignItems: "center", justifyContent: "center", }, diff --git a/src/components/maps/queue-map.web.tsx b/src/components/maps/queue-map.web.tsx index 620563b..6c5c382 100644 --- a/src/components/maps/queue-map.web.tsx +++ b/src/components/maps/queue-map.web.tsx @@ -3,11 +3,15 @@ import { useTranslation } from "react-i18next"; import { Pressable, Text, View } from "react-native"; import { AppSymbol } from "@/components/ui/app-symbol"; -import { BrandRadius, BrandType } from "@/constants/brand"; +import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; import { useBrand } from "@/hooks/use-brand"; import type { QueueMapProps } from "./queue-map.types"; import { buildCoverageNodes, getResponseLabel, getZone } from "./queue-map.web.helpers"; +const MAP_RADIUS = 28; +const INNER_RADIUS = 22; +const MAP_MIN_HEIGHT = 320; + export function QueueMap(props: QueueMapProps) { const { t, i18n } = useTranslation(); const palette = useBrand(); @@ -37,13 +41,13 @@ export function QueueMap(props: QueueMapProps) { style={{ flex: 1, backgroundColor: palette.surfaceAlt as string, - padding: 20, + padding: BrandSpacing.lg, }} > @@ -66,7 +70,7 @@ export function QueueMap(props: QueueMapProps) { gap: 12, }} > - + @@ -92,9 +93,9 @@ export function QueueMap(props: QueueMapProps) { @@ -187,29 +188,29 @@ export function QueueMap(props: QueueMapProps) { /> - + - + @@ -380,9 +379,7 @@ export function QueueMap(props: QueueMapProps) { @@ -393,12 +390,12 @@ export function QueueMap(props: QueueMapProps) { {selectedPreview.length > 0 ? ( - + {selectedPreview.map((node) => ( ; @@ -52,17 +55,17 @@ function StatusDot({ tone, palette }: { tone: StatusTone; palette: BrandPalette const colors: Record = { success: palette.success as string, warning: palette.warning as string, - danger: palette.danger, + danger: palette.danger as string, primary: palette.primary as string, - neutral: palette.textMuted, + muted: palette.textMuted as string, }; return ( ); @@ -133,22 +136,20 @@ export function PaymentActivityList({ flexDirection: "row", alignItems: "center", justifyContent: "space-between", - paddingVertical: 12, + paddingVertical: BrandSpacing.md, paddingHorizontal: BrandSpacing.md, backgroundColor: pressed && onSelectPaymentId ? palette.surfaceAlt : "transparent", borderBottomWidth: index < items.length - 1 ? 1 : 0, borderBottomColor: palette.border, })} > - + - - {sportLabel} - + {sportLabel} {item.job ? formatDateTime(item.job.startTime, locale) @@ -179,7 +180,7 @@ export function PaymentActivityList({ item.payment.currency, )} - + {paymentStatus} diff --git a/src/components/profile/profile-tab/profile-mobile-hero.tsx b/src/components/profile/profile-tab/profile-mobile-hero.tsx index da53a45..15f4c0f 100644 --- a/src/components/profile/profile-tab/profile-mobile-hero.tsx +++ b/src/components/profile/profile-tab/profile-mobile-hero.tsx @@ -6,7 +6,11 @@ import { IconButton } from "@/components/ui/icon-button"; import { IconSymbol } from "@/components/ui/icon-symbol"; import { ProfileAvatar } from "@/components/ui/profile-avatar"; import type { BrandPalette } from "@/constants/brand"; -import { BrandSpacing, BrandType } from "@/constants/brand"; +import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; + +const AVATAR_SIZE = 72; +const ICON_SIZE = 48; +const ICON_SYMBOL_SIZE = 21; import { getActiveSocialCount, getProfileSummary, @@ -88,7 +92,7 @@ export const ProfileHeaderSheet = memo(function ProfileHeaderSheet({ imageUrl={profileImageUrl} fallbackName={profileName} palette={palette} - size={72} + size={AVATAR_SIZE} roundedSquare /> @@ -117,14 +121,14 @@ export const ProfileHeaderSheet = memo(function ProfileHeaderSheet({ > {profileName} - + {resolvedStatusLabel ? ( - + } + size={ICON_SIZE} + icon={} /> diff --git a/src/components/ui/action-button.tsx b/src/components/ui/action-button.tsx index a2a964a..17e16eb 100644 --- a/src/components/ui/action-button.tsx +++ b/src/components/ui/action-button.tsx @@ -8,6 +8,10 @@ type ActionButtonTone = "primary" | "secondary"; type ActionButtonShape = "pill" | "square"; type ActionButtonSize = "md" | "lg"; +const BUTTON_HEIGHT_LG = 54; +const BUTTON_HEIGHT_MD = 42; +const BUTTON_MIN_WIDTH = 96; + type ActionButtonProps = { label?: string; onPress: () => void; @@ -44,8 +48,8 @@ export function ActionButton({ const isDisabled = disabled || loading; const isIconOnly = Boolean(icon) && !label; - const minHeight = size === "lg" ? 54 : 42; - const minWidth = shape === "square" ? minHeight : 96; + const minHeight = size === "lg" ? BUTTON_HEIGHT_LG : BUTTON_HEIGHT_MD; + const minWidth = shape === "square" ? minHeight : BUTTON_MIN_WIDTH; const useMeshGradient = meshGradient && tone === "primary" && !isDisabled; const backgroundColor = isDisabled ? tone === "primary" diff --git a/src/components/ui/address-autocomplete.tsx b/src/components/ui/address-autocomplete.tsx index c9ab58c..003b3ec 100644 --- a/src/components/ui/address-autocomplete.tsx +++ b/src/components/ui/address-autocomplete.tsx @@ -11,6 +11,7 @@ import { type PlacePrediction, resetPlacesSession, } from "@/lib/google-places"; +import { BrandRadius, BrandSpacing } from "@/constants/brand"; type AddressAutocompleteProps = { value: string; @@ -205,29 +206,29 @@ const styles = StyleSheet.create({ }, input: { borderWidth: 1, - borderRadius: 12, + borderRadius: BrandRadius.buttonSubtle, borderCurve: "continuous", - minHeight: 46, - paddingHorizontal: 12, - paddingVertical: 10, + minHeight: BrandSpacing.iconContainer + BrandSpacing.xs, + paddingHorizontal: BrandSpacing.md, + paddingVertical: BrandSpacing.sm, fontSize: 16, }, loadingBar: { - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: 8, - marginTop: 4, + paddingHorizontal: BrandSpacing.md, + paddingVertical: BrandSpacing.xs, + borderRadius: BrandRadius.cardSubtle, + marginTop: BrandSpacing.xs, }, dropdown: { borderWidth: 1, - borderRadius: 12, + borderRadius: BrandRadius.buttonSubtle, borderCurve: "continuous", - marginTop: 4, + marginTop: BrandSpacing.xs, overflow: "hidden", }, suggestion: { - paddingHorizontal: 12, - paddingVertical: 10, - gap: 2, + paddingHorizontal: BrandSpacing.md, + paddingVertical: BrandSpacing.sm, + gap: BrandSpacing.xs, }, }); diff --git a/src/components/ui/kit/kit-button-group.tsx b/src/components/ui/kit/kit-button-group.tsx index c3f7de0..ec2d0a3 100644 --- a/src/components/ui/kit/kit-button-group.tsx +++ b/src/components/ui/kit/kit-button-group.tsx @@ -1,8 +1,8 @@ import type { ReactNode } from "react"; -import type { ColorValue, DimensionValue, TextStyle, ViewStyle } from "react-native"; +import type { DimensionValue, TextStyle, ViewStyle } from "react-native"; import { Pressable, StyleSheet, Text, View } from "react-native"; -import { BrandType } from "@/constants/brand"; +import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; import { useBrand } from "@/hooks/use-brand"; import { triggerSelectionHaptic } from "./native-interaction"; @@ -31,20 +31,20 @@ export type KitButtonGroupProps = { maxWidth?: number; showSeparators?: boolean; style?: ViewStyle; - groupBackgroundColor?: ColorValue; - selectedBackgroundColor?: ColorValue; - labelColor?: ColorValue; - selectedLabelColor?: ColorValue; - dividerColor?: ColorValue; + groupBackgroundColor?: string; + selectedBackgroundColor?: string; + labelColor?: string; + selectedLabelColor?: string; + dividerColor?: string; }; const SIZE_PRESET: Record< KitButtonGroupSize, { minHeight: number; radius: number; paddingX: number; inset: number; separatorInset: number } > = { - sm: { minHeight: 40, radius: 10, paddingX: 12, inset: 2, separatorInset: 9 }, - md: { minHeight: 48, radius: 12, paddingX: 16, inset: 3, separatorInset: 11 }, - lg: { minHeight: 54, radius: 14, paddingX: 18, inset: 3, separatorInset: 12 }, + sm: { minHeight: BrandSpacing.iconContainer, radius: BrandRadius.buttonSubtle, paddingX: BrandSpacing.componentPadding, inset: 2, separatorInset: BrandSpacing.sm + 1 }, + md: { minHeight: BrandSpacing.iconContainer, radius: BrandRadius.button, paddingX: BrandSpacing.lg, inset: 3, separatorInset: BrandSpacing.sm + 3 }, + lg: { minHeight: BrandSpacing.xxl + 6, radius: BrandRadius.button, paddingX: BrandSpacing.xl - 2, inset: 3, separatorInset: BrandSpacing.sm + 4 }, }; export function KitButtonGroup({ @@ -72,33 +72,23 @@ export function KitButtonGroup({ const wraps = resolvedColumns < options.length; const slotBasis = `${100 / resolvedColumns}%` as DimensionValue; - const toneDefaults = - tone === "onPrimary" - ? { - groupBackgroundColor: "rgba(18, 11, 31, 0.78)", - selectedBackgroundColor: "rgba(255, 255, 255, 0.2)", - labelColor: "rgba(255, 255, 255, 0.72)", - selectedLabelColor: palette.onPrimary as string, - dividerColor: "rgba(255, 255, 255, 0.14)", - } - : { - groupBackgroundColor: palette.surfaceAlt as string, - selectedBackgroundColor: palette.surfaceElevated as string, - labelColor: palette.textMuted as string, - selectedLabelColor: palette.text as string, - dividerColor: palette.borderStrong as string, - }; + const resolvedGroupBg = groupBackgroundColor ?? (tone === "onPrimary" ? `${String(palette.text)}CC` : String(palette.surfaceAlt)); + const resolvedSelectedBg = selectedBackgroundColor ?? (tone === "onPrimary" ? `${String(palette.onPrimary)}33` : String(palette.surfaceElevated)); + const resolvedLabelColorFinal = labelColor ?? (tone === "onPrimary" ? `${String(palette.onPrimary)}B8` : String(palette.textMuted)); + const resolvedSelectedLabelColorFinal = selectedLabelColor ?? String(palette.onPrimary); + const resolvedDividerColorFinal = dividerColor ?? (tone === "onPrimary" ? `${String(palette.onPrimary)}24` : String(palette.borderStrong)); return ( ({ { top: metrics.separatorInset, bottom: metrics.separatorInset, - backgroundColor: (dividerColor ?? toneDefaults.dividerColor) as string, + backgroundColor: resolvedDividerColorFinal, }, ]} /> @@ -144,8 +134,7 @@ export function KitButtonGroup({ bottom: metrics.inset, left: metrics.inset, borderRadius: metrics.radius, - backgroundColor: (selectedBackgroundColor ?? - toneDefaults.selectedBackgroundColor) as string, + backgroundColor: resolvedSelectedBg, }, ]} /> @@ -164,16 +153,16 @@ export function KitButtonGroup({ styles.segmentPressable, { opacity: option.disabled ? 0.45 : pressed ? 0.9 : 1, - } as ViewStyle, + }, ]} > {option.icon ? {option.icon} : null} @@ -182,9 +171,7 @@ export function KitButtonGroup({ style={[ styles.label as TextStyle, { - color: selected - ? (selectedLabelColor ?? toneDefaults.selectedLabelColor) - : (labelColor ?? toneDefaults.labelColor), + color: selected ? resolvedSelectedLabelColorFinal : resolvedLabelColorFinal, }, ]} > @@ -209,9 +196,9 @@ const styles = StyleSheet.create({ group: { flexDirection: "row", alignItems: "stretch", - borderRadius: 20, + borderRadius: BrandRadius.card, borderCurve: "continuous", - padding: 6, + padding: BrandSpacing.xs + 2, overflow: "hidden", }, slot: { @@ -236,7 +223,7 @@ const styles = StyleSheet.create({ flexDirection: "row", alignItems: "center", justifyContent: "center", - gap: 6, + gap: BrandSpacing.xs + 2, position: "relative", zIndex: 1, }, @@ -246,7 +233,6 @@ const styles = StyleSheet.create({ }, label: { ...BrandType.bodyMedium, - fontSize: 15, fontWeight: "700", includeFontPadding: false, textAlign: "center", diff --git a/src/components/ui/kit/kit-chip.tsx b/src/components/ui/kit/kit-chip.tsx index ce7f54d..4d04bd6 100644 --- a/src/components/ui/kit/kit-chip.tsx +++ b/src/components/ui/kit/kit-chip.tsx @@ -1,6 +1,6 @@ import { Pressable, Text } from "react-native"; -import { BrandRadius, BrandType } from "@/constants/brand"; +import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; import { useBrand } from "@/hooks/use-brand"; import { triggerSelectionHaptic } from "./native-interaction"; import type { KitChipProps } from "./types"; @@ -25,13 +25,13 @@ export function KitChip({ }} style={({ pressed }) => [ { - minHeight: 40, - borderRadius: BrandRadius.button - 4, + minHeight: BrandSpacing.iconContainer, + borderRadius: BrandRadius.buttonSubtle, borderCurve: "continuous", alignItems: "center", justifyContent: "center", - paddingHorizontal: 14, - paddingVertical: 8, + paddingHorizontal: BrandSpacing.componentPadding, + paddingVertical: BrandSpacing.sm, backgroundColor: selected ? (palette.primary as string) : (palette.surfaceAlt as string), opacity: disabled ? 0.72 : 1, transform: [{ scale: pressed && !disabled ? 0.985 : 1 }], @@ -43,7 +43,6 @@ export function KitChip({ style={{ ...BrandType.micro, color: selected ? (palette.onPrimary as string) : (palette.text as string), - letterSpacing: 0.15, includeFontPadding: false, }} > diff --git a/src/components/ui/kit/kit-disclosure-button-group.tsx b/src/components/ui/kit/kit-disclosure-button-group.tsx index 89c2582..4bf7d48 100644 --- a/src/components/ui/kit/kit-disclosure-button-group.tsx +++ b/src/components/ui/kit/kit-disclosure-button-group.tsx @@ -8,7 +8,7 @@ import Animated, { ReduceMotion, } from "react-native-reanimated"; -import { BrandType } from "@/constants/brand"; +import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; import { useBrand } from "@/hooks/use-brand"; import { triggerSelectionHaptic } from "./native-interaction"; @@ -39,20 +39,20 @@ type KitDisclosureButtonGroupProps = { const SIZE_PRESETS = { sm: { - railPadding: 4, - railRadius: 16, - sectionRadius: 12, - minHeight: 40, - paddingHorizontal: 14, - separatorInset: 10, + railPadding: BrandSpacing.xs, + railRadius: BrandRadius.cardSubtle, + sectionRadius: BrandRadius.buttonSubtle, + minHeight: BrandSpacing.iconContainer, + paddingHorizontal: BrandSpacing.componentPadding, + separatorInset: BrandSpacing.sm + 2, }, md: { - railPadding: 5, - railRadius: 18, - sectionRadius: 14, - minHeight: 44, - paddingHorizontal: 16, - separatorInset: 11, + railPadding: BrandSpacing.xs + 1, + railRadius: BrandRadius.cardSubtle + 2, + sectionRadius: BrandRadius.buttonSubtle, + minHeight: BrandSpacing.iconContainer + 6, + paddingHorizontal: BrandSpacing.lg, + separatorInset: BrandSpacing.sm + 3, }, } as const; @@ -77,15 +77,16 @@ export function KitDisclosureButtonGroup({ }: KitDisclosureButtonGroupProps) { const palette = useBrand(); const metrics = SIZE_PRESETS[size]; - const resolvedRailColor = railColor ?? "rgba(24, 14, 46, 0.66)"; - const resolvedSelectedColor = selectedColor ?? "rgba(255, 255, 255, 0.18)"; - const resolvedLabelColor = labelColor ?? "rgba(255, 255, 255, 0.72)"; - const resolvedSelectedLabelColor = selectedLabelColor ?? (palette.onPrimary as string); - const resolvedDividerColor = dividerColor ?? "rgba(255, 255, 255, 0.12)"; + const resolvedRailColor = railColor ?? `${String(palette.text)}CC`; + const resolvedSelectedColor = selectedColor ?? `${String(palette.onPrimary)}33`; + const resolvedLabelColor = labelColor ?? `${String(palette.onPrimary)}B8`; + const resolvedSelectedLabelColor = selectedLabelColor ?? String(palette.onPrimary); + const resolvedDividerColor = dividerColor ?? `${String(palette.onPrimary)}1F`; return ( ({ layout={DISCLOSURE_LAYOUT} entering={FadeInRight.duration(180)} exiting={FadeOutRight.duration(140)} - style={styles.optionsRow} + className="flex-row items-stretch" > {options.map((option, index) => { const selected = option.value === value; @@ -113,7 +114,6 @@ export function KitDisclosureButtonGroup({ pointerEvents="none" style={[ styles.divider, - { backgroundColor: "rgba(255, 255, 255, 0.12)" }, { top: metrics.separatorInset, bottom: metrics.separatorInset, @@ -149,8 +149,8 @@ export function KitDisclosureButtonGroup({ style={({ pressed }) => [styles.segmentButton, { opacity: pressed ? 0.9 : 1 }]} > ({ ]} > {triggerIcon ? {triggerIcon} : null} {triggerLabel ? ( - + {triggerLabel} ) : null} @@ -244,7 +245,7 @@ const styles = StyleSheet.create({ flexDirection: "row", alignItems: "center", justifyContent: "center", - gap: 6, + gap: BrandSpacing.xs + 2, }, triggerPressable: { justifyContent: "center", @@ -253,7 +254,7 @@ const styles = StyleSheet.create({ alignItems: "center", justifyContent: "center", flexDirection: "row", - gap: 6, + gap: BrandSpacing.xs + 2, }, iconWrap: { alignItems: "center", @@ -261,7 +262,6 @@ const styles = StyleSheet.create({ }, segmentLabel: { ...BrandType.bodyMedium, - fontSize: 15, fontWeight: "700", includeFontPadding: false, textAlign: "center", diff --git a/src/components/ui/kit/kit-floating-badge.tsx b/src/components/ui/kit/kit-floating-badge.tsx index a8dbfe9..0e05201 100644 --- a/src/components/ui/kit/kit-floating-badge.tsx +++ b/src/components/ui/kit/kit-floating-badge.tsx @@ -9,12 +9,13 @@ import Animated, { ZoomIn, } from "react-native-reanimated"; +import { BrandRadius, BrandSpacing } from "@/constants/brand"; import type { KitFloatingBadgeProps } from "./types"; export function KitFloatingBadge({ children, visible = true, - size = 24, + size = BrandSpacing.xxl - 8, backgroundColor, borderColor, motion = "float", @@ -29,7 +30,7 @@ export function KitFloatingBadge({ } floatOffset.value = withRepeat( - withSequence(withTiming(-3, { duration: 900 }), withTiming(0, { duration: 900 })), + withSequence(withTiming(-BrandSpacing.xs - 1, { duration: 900 }), withTiming(0, { duration: 900 })), -1, false, ); @@ -47,16 +48,14 @@ export function KitFloatingBadge({ {leading ? {leading} : null} - + {title ? ( {title} @@ -101,7 +101,7 @@ export function KitListItem({ alignItems: "center", paddingHorizontal: BrandSpacing.lg, paddingVertical: BrandSpacing.md, - minHeight: 56, + minHeight: BrandSpacing.xxl + 24, backgroundColor: pressed ? background.surfaceSecondary : background.surfaceElevated, }, style, @@ -114,13 +114,12 @@ export function KitListItem({ return ( ({ return ( ({ }} style={({ pressed }) => ({ flex: 1, - minHeight: 48, - borderRadius: BrandRadius.button - 8, + minHeight: BrandSpacing.iconContainer + 10, + borderRadius: BrandRadius.buttonSubtle, borderCurve: "continuous", backgroundColor: selected ? (palette.primary as string) @@ -69,7 +64,6 @@ export function KitSegmentedToggle({ style={{ ...BrandType.micro, color: selected ? (palette.onPrimary as string) : (palette.primary as string), - fontSize: 14, fontWeight: "700", includeFontPadding: false, }} diff --git a/src/components/ui/kit/kit-status-badge.tsx b/src/components/ui/kit/kit-status-badge.tsx index 7d4ac71..1f1166b 100644 --- a/src/components/ui/kit/kit-status-badge.tsx +++ b/src/components/ui/kit/kit-status-badge.tsx @@ -1,6 +1,7 @@ import { View } from "react-native"; import { ThemedText } from "@/components/themed-text"; +import { BrandRadius, BrandSpacing } from "@/constants/brand"; import type { KitStatusBadgeProps } from "./types"; import { useKitTheme } from "./use-kit-theme"; @@ -45,16 +46,12 @@ export function KitStatusBadge({ return ( diff --git a/src/components/ui/kit/kit-success-burst.tsx b/src/components/ui/kit/kit-success-burst.tsx index 339616c..949fc51 100644 --- a/src/components/ui/kit/kit-success-burst.tsx +++ b/src/components/ui/kit/kit-success-burst.tsx @@ -10,6 +10,7 @@ import Animated, { } from "react-native-reanimated"; import { AppSymbol } from "@/components/ui/app-symbol"; +import { BrandSpacing } from "@/constants/brand"; import { useKitTheme } from "./use-kit-theme"; type BurstBubbleConfig = { @@ -20,11 +21,11 @@ type BurstBubbleConfig = { }; const BUBBLES: readonly BurstBubbleConfig[] = [ - { id: "left-top", x: -48, y: -12, size: 10 }, - { id: "right-top", x: 46, y: -18, size: 12 }, - { id: "left-bottom", x: -34, y: 28, size: 8 }, - { id: "right-bottom", x: 38, y: 26, size: 10 }, - { id: "top", x: 0, y: -42, size: 9 }, + { id: "left-top", x: -BrandSpacing.xxl * 2 - 4, y: -BrandSpacing.md - 4, size: BrandSpacing.sm + 2 }, + { id: "right-top", x: BrandSpacing.xxl * 2 - 2, y: -BrandSpacing.md - 6, size: BrandSpacing.sm + 4 }, + { id: "left-bottom", x: -BrandSpacing.xxl - 2, y: BrandSpacing.lg + 12, size: BrandSpacing.xs + 4 }, + { id: "right-bottom", x: BrandSpacing.xxl - 2, y: BrandSpacing.lg + 10, size: BrandSpacing.sm + 2 }, + { id: "top", x: 0, y: -BrandSpacing.xxl * 2 + 2, size: BrandSpacing.xs + 5 }, ] as const; type KitSuccessBurstProps = { @@ -56,12 +57,11 @@ function BurstBubble({ return ( + - + ); diff --git a/src/components/ui/kit/kit-surface.tsx b/src/components/ui/kit/kit-surface.tsx index fd0acd1..2a3bf0a 100644 --- a/src/components/ui/kit/kit-surface.tsx +++ b/src/components/ui/kit/kit-surface.tsx @@ -1,7 +1,7 @@ import type { ComponentType } from "react"; import { View, type ViewProps } from "react-native"; -import { BrandRadius } from "@/constants/brand"; +import { BrandRadius, BrandSpacing } from "@/constants/brand"; import { useBrand } from "@/hooks/use-brand"; import { useThemePreference } from "@/hooks/use-theme-preference"; @@ -40,8 +40,8 @@ function getGlassModule(): GlassModule | null { export function KitSurface({ tone = "base", - padding = 16, - gap = 10, + padding = BrandSpacing.lg, + gap = BrandSpacing.sm + 2, children, style, ...rest diff --git a/src/components/ui/kit/kit-text-field.tsx b/src/components/ui/kit/kit-text-field.tsx index 94648d9..09640be 100644 --- a/src/components/ui/kit/kit-text-field.tsx +++ b/src/components/ui/kit/kit-text-field.tsx @@ -1,6 +1,6 @@ import { Text, TextInput, View } from "react-native"; -import { BrandRadius } from "@/constants/brand"; +import { BrandSpacing, BrandType } from "@/constants/brand"; import { useBrand } from "@/hooks/use-brand"; import type { KitTextFieldProps } from "./types"; @@ -19,11 +19,11 @@ export function KitTextField({ const isMultiline = Boolean(inputProps.multiline); return ( - + {label ? ( ) : null} {trailing} : null} {hasError ? ( - + {errorText} ) : helperText ? ( {helperText} diff --git a/src/components/ui/native-search-field.tsx b/src/components/ui/native-search-field.tsx index 5becc40..bc42932 100644 --- a/src/components/ui/native-search-field.tsx +++ b/src/components/ui/native-search-field.tsx @@ -11,6 +11,24 @@ import { BrandRadius, BrandSpacing } from "@/constants/brand"; import { useBrand } from "@/hooks/use-brand"; import { useThemePreference } from "@/hooks/use-theme-preference"; +const SEARCH_SIZE_SM = { + containerMinHeight: 48, + inputMinHeight: 44, + horizontalPadding: BrandSpacing.md, + iconSize: 18, + clearIconSize: 17, + radius: 18, +} as const; + +const SEARCH_SIZE_MD = { + containerMinHeight: 52, + inputMinHeight: 48, + horizontalPadding: BrandSpacing.lg, + iconSize: 19, + clearIconSize: 18, + radius: BrandRadius.input, +} as const; + type NativeSearchFieldProps = Omit & { value: string; onChangeText: (value: string) => void; @@ -35,24 +53,7 @@ export function NativeSearchField({ resolvedScheme === "dark" ? (palette.surfaceElevated as string) : (palette.surfaceAlt as string); - const metrics = - size === "sm" - ? { - containerMinHeight: 48, - inputMinHeight: 44, - horizontalPadding: BrandSpacing.md, - iconSize: 18, - clearIconSize: 17, - radius: 18, - } - : { - containerMinHeight: 52, - inputMinHeight: 48, - horizontalPadding: BrandSpacing.lg, - iconSize: 19, - clearIconSize: 18, - radius: BrandRadius.input, - }; + const metrics = size === "sm" ? SEARCH_SIZE_SM : SEARCH_SIZE_MD; return ( ; +} + +export const Image = (props: React.ComponentProps & { className?: string }) => { + return useCssElement(CSSImage, props, { className: "style" }); +}; +Image.displayName = "CSS(Image)"; diff --git a/src/tw/index.tsx b/src/tw/index.tsx new file mode 100644 index 0000000..76af563 --- /dev/null +++ b/src/tw/index.tsx @@ -0,0 +1,5 @@ +// NativeWind v5 handles className transformation via Metro import rewriting +// These re-export from react-native with automatic className support +export { View, Text, Pressable, ScrollView, TextInput, TouchableHighlight, StyleSheet } from "react-native"; +export { Link } from "expo-router"; +export { default as Animated, createAnimatedComponent } from "react-native-reanimated"; diff --git a/tsconfig.json b/tsconfig.json index 72a3c47..0fef3af 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,9 +8,14 @@ "noUnusedLocals": true, "noUnusedParameters": true, "resolveJsonModule": true, - "types": ["bun-types"], + "types": [ + "bun-types" + ], "paths": { - "@/*": ["./src/*", "./*"] + "@/*": [ + "./src/*", + "./*" + ] }, "incremental": true, "skipLibCheck": true @@ -22,7 +27,8 @@ "convex/**/*.ts", "convex/**/*.tsx", ".expo/types/**/*.ts", - "expo-env.d.ts" + "expo-env.d.ts", + "nativewind-env.d.ts" ], "exclude": [ "node_modules", @@ -34,4 +40,4 @@ "ios", "scripts/android/sdk-proxy" ] -} +} \ No newline at end of file From 44f8afa0cea4f185de00bffaae26b72a389f0313 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 23 Mar 2026 14:17:31 +0200 Subject: [PATCH 02/44] fix: add asset module declarations for nativewind branch --- src/types/assets.d.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/types/assets.d.ts diff --git a/src/types/assets.d.ts b/src/types/assets.d.ts new file mode 100644 index 0000000..a48e36a --- /dev/null +++ b/src/types/assets.d.ts @@ -0,0 +1,14 @@ +declare module "*.jpg" { + const value: number; + export default value; +} + +declare module "*.jpeg" { + const value: number; + export default value; +} + +declare module "*.png" { + const value: number; + export default value; +} From 72e5dcd69cd760c11f8a3f17696de906d8e7c288 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 23 Mar 2026 16:12:04 +0200 Subject: [PATCH 03/44] feat: nativewind v5 theme tokens, streamlined spacing/radius/colors in global.css MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite global.css with full @theme token set (spacing, radius, colors) - Add dark mode via prefers-color-scheme media query - Add missing spacing tokens (componentPadding, iconContainer, iconContainerLarge, haloSize, mapMinHeight, multilineInputMinHeight) - Add radius-circle and radius-pill - Add all semantic color tokens (primary, success, danger, warning, surfaces, borders, text) - Fix kit components: kit-button-group, kit-chip, kit-disclosure-button-group, kit-segmented-toggle, kit-status-badge, kit-text-field, kit-surface - Fix payments.tsx: bulk normalize vars() ColorValue → String() - Fix sign-in-screen.tsx: remove arbitrary gap-[10px] spacing values - Fix calendar-settings.tsx: contentContainerStyle string → object - Fix modal.tsx and _layout.tsx: tokenized spacing --- .../instructor/profile/payments.tsx | 645 ++++++++---------- .../studio/profile/calendar-settings.tsx | 92 +-- .../(studio-tabs)/studio/profile/index.tsx | 2 +- src/app/(auth)/sign-in-screen.tsx | 79 +-- src/app/_layout.tsx | 26 +- src/app/modal.tsx | 20 +- src/components/home/home-agenda-widget.tsx | 2 +- src/components/home/home-header-sheet.tsx | 6 +- .../jobs/instructor/instructor-job-card.tsx | 3 +- .../jobs/studio/studio-jobs-list-parts.tsx | 6 +- src/components/loading-screen.tsx | 15 +- .../map-tab/map-tab/map-web-command-panel.tsx | 15 +- .../map-tab/map-tab/map-web-header-panels.tsx | 7 +- src/components/maps/queue-map.native.tsx | 11 +- src/components/maps/queue-map.web.tsx | 7 +- .../profile-tab/profile-mobile-hero.tsx | 6 +- src/components/ui/action-button.tsx | 9 +- src/components/ui/kit/kit-button-group.tsx | 73 +- src/components/ui/kit/kit-chip.tsx | 6 +- .../ui/kit/kit-disclosure-button-group.tsx | 101 +-- .../ui/kit/kit-segmented-toggle.tsx | 5 +- src/components/ui/kit/kit-status-badge.tsx | 4 +- src/components/ui/kit/kit-text-field.tsx | 8 +- src/components/ui/native-search-field.tsx | 19 +- src/global.css | 57 ++ 25 files changed, 518 insertions(+), 706 deletions(-) diff --git a/src/app/(app)/(instructor-tabs)/instructor/profile/payments.tsx b/src/app/(app)/(instructor-tabs)/instructor/profile/payments.tsx index bafa467..d8da912 100644 --- a/src/app/(app)/(instructor-tabs)/instructor/profile/payments.tsx +++ b/src/app/(app)/(instructor-tabs)/instructor/profile/payments.tsx @@ -8,6 +8,7 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Alert, Platform, Pressable, View } from "react-native"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; +import { vars } from "nativewind"; import { LoadingScreen } from "@/components/loading-screen"; import { PaymentActivityList } from "@/components/payments/payment-activity-list"; import { @@ -377,26 +378,33 @@ export default function ProfilePaymentsScreen() { return ( {t("profile.payments.finalizingTitle")} - + {t("profile.payments.finalizingBody")} @@ -408,27 +416,33 @@ export default function ProfilePaymentsScreen() { return ( {t("profile.payments.successTitle")} - + {t("profile.payments.successBody")} @@ -441,63 +455,61 @@ export default function ProfilePaymentsScreen() { - + {t("profile.payments.verifyToConnectBankTitle")} {t("profile.payments.verifyToConnectBankBody")} - + {t("profile.identityVerification.providerPill")} @@ -508,18 +520,19 @@ export default function ProfilePaymentsScreen() { setShowVerifyModal(false); void router.push(INSTRUCTOR_IDENTITY_VERIFICATION_ROUTE as Href); }} - style={({ pressed }) => ({ - width: "100%", - paddingVertical: BrandSpacing.md, - paddingHorizontal: BrandSpacing.md + 2, - borderRadius: BrandRadius.button, - borderCurve: "continuous", - alignItems: "center", - backgroundColor: palette.didit.accent, - opacity: pressed ? 0.85 : 1, - })} + className="w-full border active:opacity-85" + style={({ pressed }) => + vars({ + "--tw-bg-primary": String(palette.didit.accent), + "--tw-text": String(palette.onPrimary), + borderRadius: BrandRadius.button, + paddingHorizontal: BrandSpacing.lg, + paddingVertical: BrandSpacing.md, + opacity: pressed ? 0.85 : 1, + }) + } > - + {t("profile.payments.verifyToConnectBankCta")} @@ -527,13 +540,10 @@ export default function ProfilePaymentsScreen() { accessibilityRole="button" accessibilityLabel={t("common.cancel")} onPress={() => setShowVerifyModal(false)} - style={({ pressed }) => ({ - paddingVertical: 10, - paddingHorizontal: 14, - opacity: pressed ? 0.6 : 1, - })} + className="active:opacity-60" + style={{ paddingHorizontal: BrandSpacing.md, paddingVertical: BrandSpacing.sm + 2 }} > - + {t("common.cancel")} @@ -546,40 +556,38 @@ export default function ProfilePaymentsScreen() { return ( - + {/* Consolidated Error/Info Banner */} {destinationError || withdrawError || preferenceError ? ( - + {destinationError || withdrawError || preferenceError} ) : destinationInfo || withdrawInfo || preferenceInfo ? ( - + {destinationInfo || withdrawInfo || preferenceInfo} @@ -610,23 +618,24 @@ export default function ProfilePaymentsScreen() { accessibilityRole="button" accessibilityLabel={t("profile.setup.verifyIdentity")} onPress={() => router.push(INSTRUCTOR_IDENTITY_VERIFICATION_ROUTE as Href)} - style={({ pressed }) => ({ - alignSelf: "flex-start", - paddingHorizontal: BrandSpacing.sm + 2, - paddingVertical: BrandSpacing.sm, - borderRadius: BrandRadius.pill, - borderCurve: "continuous", - backgroundColor: pressed ? palette.surfaceAlt : palette.primarySubtle, - borderWidth: 1, - borderColor: palette.primary as string, - })} + className="self-start rounded-full border active:bg-surface-alt" + style={({ pressed }) => + vars({ + "--tw-bg-primary-subtle": String(palette.primarySubtle), + "--tw-border": String(palette.primary), + "--tw-text-primary": String(palette.primary), + paddingHorizontal: BrandSpacing.md, + paddingVertical: BrandSpacing.sm, + backgroundColor: pressed ? palette.surfaceAlt : palette.primarySubtle, + }) + } > - + {t("profile.setup.verifyIdentity")} ) : !payoutSummary?.hasVerifiedDestination ? ( - + {payoutSummary?.onboardingStatus === "pending" ? t("profile.payments.onboardingPending") : payoutSummary?.onboardingStatus === "failed" @@ -639,32 +648,15 @@ export default function ProfilePaymentsScreen() { {/* Hero Balance Card */} - - + + {t("profile.payments.available")} @@ -672,16 +664,16 @@ export default function ProfilePaymentsScreen() { numberOfLines={1} minimumFontScale={0.76} adjustsFontSizeToFit - style={{ - color: palette.onPrimary, - fontSize: BrandType.display.fontSize, - lineHeight: BrandType.display.lineHeight, - fontWeight: "800", - fontVariant: ["tabular-nums"], - marginTop: BrandSpacing.xs, - letterSpacing: -1, - flexShrink: 1, - }} + className="mt-1 shrink" + style={vars( + { + "--tw-text-primary": String(palette.onPrimary), + fontSize: BrandType.display.fontSize, + lineHeight: BrandType.display.lineHeight, + fontWeight: BrandType.display.fontWeight, + letterSpacing: BrandType.display.letterSpacing, + }, + )} > {formatAgorotCurrency( payoutSummary?.availableAmountAgorot ?? 0, @@ -691,46 +683,38 @@ export default function ProfilePaymentsScreen() { - + {payoutSummary?.currency ?? "ILS"} - + { + className="flex-1 flex-row items-center justify-center overflow-hidden active:scale-[0.985]" + style={() => { const isDisabled = !isManualPayoutMode || !isIdentityVerified || !payoutSummary?.hasVerifiedDestination || (payoutSummary?.availableAmountAgorot ?? 0) <= 0; - const bgOpacity = isDisabled ? 0.1 : 0.25; - return { - flex: 1, - backgroundColor: palette.onPrimary, - opacity: withdrawBusy ? 0.5 : bgOpacity, - borderRadius: BrandRadius.button, - minHeight: 54, - padding: BrandSpacing.sm + 6, - alignItems: "center", - borderCurve: "continuous", - flexDirection: "row", - justifyContent: "center", - gap: BrandSpacing.sm, - overflow: "hidden", - transform: [{ scale: pressed ? 0.985 : 1 }], - }; + return vars( + { + "--tw-bg-primary": String(palette.onPrimary), + "--tw-text-primary": String(palette.onPrimary), + minHeight: BrandSpacing.xxl + BrandSpacing.xl, + gap: BrandSpacing.sm, + borderRadius: BrandRadius.button, + paddingHorizontal: BrandSpacing.lg, + paddingVertical: BrandSpacing.md, + opacity: withdrawBusy ? 0.5 : isDisabled ? 0.1 : 0.25, + }, + ); }} onPress={() => { confirmWithdrawToBank(); @@ -744,7 +728,7 @@ export default function ProfilePaymentsScreen() { } > - + {t("profile.payments.withdraw")} @@ -756,33 +740,23 @@ export default function ProfilePaymentsScreen() { ? t("profile.payments.manageBank") : t("profile.payments.connectBank") } - style={({ pressed }) => ({ - flex: 1, - backgroundColor: payoutSummary?.hasVerifiedDestination - ? palette.onPrimary - : palette.text, - opacity: payoutSummary?.hasVerifiedDestination - ? pressed - ? 0.2 - : 0.14 - : pressed - ? 0.88 - : 1, - borderRadius: BrandRadius.button, - minHeight: 54, - padding: BrandSpacing.sm + 6, - alignItems: "center", - borderCurve: "continuous", - flexDirection: "row", - justifyContent: "center", - gap: BrandSpacing.sm, - overflow: "hidden", - borderWidth: 1, - borderColor: payoutSummary?.hasVerifiedDestination - ? palette.onPrimary - : palette.border, - transform: [{ scale: pressed ? 0.985 : 1 }], - })} + className="flex-1 flex-row items-center justify-center border active:scale-[0.985]" + style={({ pressed }) => { + const hasDestination = payoutSummary?.hasVerifiedDestination; + return vars( + { + "--tw-bg-primary": String(hasDestination ? palette.onPrimary : palette.text), + "--tw-border": String(hasDestination ? palette.onPrimary : palette.border), + "--tw-text-primary": String(palette.onPrimary), + minHeight: BrandSpacing.xxl + BrandSpacing.xl, + gap: BrandSpacing.sm, + borderRadius: BrandRadius.button, + paddingHorizontal: BrandSpacing.lg, + paddingVertical: BrandSpacing.md, + opacity: hasDestination ? (pressed ? 0.2 : 0.14) : pressed ? 0.88 : 1, + }, + ); + }} onPress={() => { if (!isIdentityVerified) { setShowVerifyModal(true); @@ -793,7 +767,7 @@ export default function ProfilePaymentsScreen() { disabled={onboardingBusy} > - + {payoutSummary?.hasVerifiedDestination ? t("profile.payments.manageBank") : t("profile.payments.connectBank")} @@ -803,20 +777,20 @@ export default function ProfilePaymentsScreen() { {/* Stats Row - Merged into Hero Card */} - - + + - + {t("profile.payments.pending")} - + {formatAgorotCurrency( payoutSummary?.pendingAmountAgorot ?? 0, locale, @@ -824,19 +798,19 @@ export default function ProfilePaymentsScreen() { )} - + - + {t("profile.payments.paid")} - + {formatAgorotCurrency( payoutSummary?.paidAmountAgorot ?? 0, locale, @@ -847,10 +821,8 @@ export default function ProfilePaymentsScreen() { - - + + {t("profile.payments.preferenceTitle")} @@ -873,7 +845,7 @@ export default function ProfilePaymentsScreen() { ]} /> - + {effectivePreferenceMode === "scheduled_date" ? t("profile.payments.preferenceScheduledHint") : effectivePreferenceMode === "manual_hold" @@ -882,23 +854,24 @@ export default function ProfilePaymentsScreen() { {effectivePreferenceMode === "scheduled_date" ? ( - + setShowSchedulePicker((value) => !value)} - style={({ pressed }) => ({ - borderRadius: BrandRadius.buttonSubtle, - borderCurve: "continuous", - borderWidth: 1, - borderColor: palette.border as string, - backgroundColor: pressed ? palette.surface : palette.appBg, - paddingHorizontal: BrandSpacing.lg, - paddingVertical: BrandSpacing.sm + 4, - gap: BrandSpacing.xs, - })} + className="border active:bg-surface" + style={({ pressed }) => + vars({ + "--tw-border": String(palette.border), + "--tw-bg-app-bg": String(palette.appBg), + borderRadius: BrandRadius.buttonSubtle, + paddingHorizontal: BrandSpacing.lg, + paddingVertical: BrandSpacing.md, + backgroundColor: pressed ? palette.surface : palette.appBg, + }) + } > - + {t("profile.payments.preferenceScheduleAt")} {scheduledAtLabel} @@ -906,15 +879,17 @@ export default function ProfilePaymentsScreen() { {showSchedulePicker ? ( setShowSchedulePicker(false)} - style={({ pressed }) => ({ - alignSelf: "flex-start", - paddingHorizontal: BrandSpacing.sm + 6, - paddingVertical: BrandSpacing.sm + 2, - borderRadius: BrandRadius.pill, - borderCurve: "continuous", - backgroundColor: pressed ? palette.surface : palette.primarySubtle, - })} + className="self-start rounded-full active:bg-surface" + style={({ pressed }) => + vars({ + "--tw-bg-primary-subtle": String(palette.primarySubtle), + paddingHorizontal: BrandSpacing.md, + paddingVertical: BrandSpacing.xs + 2, + backgroundColor: pressed ? palette.surface : palette.primarySubtle, + }) + } > - + {t("common.done")} @@ -953,7 +929,7 @@ export default function ProfilePaymentsScreen() { ) : null} - + ({ - flex: 1, - alignItems: "center", - justifyContent: "center", - minHeight: BrandSpacing.iconContainer + 6, - borderRadius: BrandRadius.buttonSubtle, - borderCurve: "continuous", - borderWidth: 1, - borderColor: palette.border as string, - backgroundColor: pressed ? palette.surface : palette.appBg, - })} + className="flex-1 items-center justify-center border active:bg-surface" + style={({ pressed }) => + vars({ + "--tw-border": String(palette.border), + "--tw-bg-app-bg": String(palette.appBg), + minHeight: BrandSpacing.xxl + BrandSpacing.md, + borderRadius: BrandRadius.buttonSubtle, + backgroundColor: pressed ? palette.surface : palette.appBg, + }) + } > {t("common.cancel")} @@ -985,19 +960,17 @@ export default function ProfilePaymentsScreen() { void savePayoutPreference("scheduled_date", scheduleDraft.getTime()); }} disabled={preferenceBusy} - style={({ pressed }) => ({ - flex: 1, - alignItems: "center", - justifyContent: "center", - minHeight: BrandSpacing.iconContainer + 6, - borderRadius: BrandRadius.buttonSubtle, - borderCurve: "continuous", - backgroundColor: palette.payments.accent, - opacity: preferenceBusy ? 0.6 : 1, - transform: [{ scale: pressed ? 0.985 : 1 }], - })} + className="flex-1 items-center justify-center active:scale-[0.985]" + style={({ pressed }) => + vars({ + "--tw-bg-payments-accent": String(palette.payments.accent), + minHeight: BrandSpacing.xxl + BrandSpacing.md, + borderRadius: BrandRadius.buttonSubtle, + opacity: preferenceBusy ? 0.6 : 1, + }) + } > - + {preferenceBusy ? t("profile.payments.preferenceSaving") : t("profile.payments.preferenceSaveSchedule")} @@ -1008,106 +981,81 @@ export default function ProfilePaymentsScreen() { ) : null} {preferenceError ? ( - + {preferenceError} ) : preferenceInfo ? ( - + {preferenceInfo} ) : null} {selectedPaymentId ? ( - - + + {t("profile.payments.receipt")} setSelectedPaymentId(null)} - style={({ pressed }) => ({ - backgroundColor: palette.surfaceAlt, - paddingHorizontal: BrandSpacing.sm + 4, - paddingVertical: BrandSpacing.xs + 2, - borderRadius: BrandRadius.pill, - opacity: pressed ? 0.84 : 1, - })} + className="rounded-full active:opacity-84" + style={vars({ "--tw-bg-surface-alt": String(palette.surfaceAlt), paddingHorizontal: BrandSpacing.md, paddingVertical: BrandSpacing.xs + 2 })} > - + {t("profile.payments.close")} {isDetailLoading ? ( - + {t("profile.payments.loadingReceipt")} ) : !selectedPaymentDetail ? ( - + {t("profile.payments.paymentNotFound")} ) : ( - + {formatAgorotCurrency( role === "studio" ? selectedPaymentDetail.payment.studioChargeAmountAgorot @@ -1116,31 +1064,21 @@ export default function ProfilePaymentsScreen() { selectedPaymentDetail.payment.currency, )} - + {formatDateTime(selectedPaymentDetail.payment.createdAt, locale)} - - - + + + {t("profile.payments.status")} {getPaymentStatusLabel(selectedPaymentDetail.payment.status)} - - + + {t("profile.payments.payout")} @@ -1158,15 +1096,10 @@ export default function ProfilePaymentsScreen() { selectedPaymentDetail.invoice!.externalInvoiceUrl!, ); }} - style={({ pressed }) => ({ - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - paddingVertical: BrandSpacing.sm, - opacity: pressed ? 0.84 : 1, - })} + className="flex-row items-center justify-between active:opacity-84" + style={{ paddingVertical: BrandSpacing.sm }} > - + {t("profile.payments.downloadInvoice")} @@ -1178,7 +1111,7 @@ export default function ProfilePaymentsScreen() { ) : null} - + + - + {googleStatus.lastError} @@ -498,20 +498,21 @@ export default function StudioCalendarSettingsScreen() { {googleConfigError ? ( - {googleConfigError} + + {googleConfigError} + ) : null} {isGoogleConnected ? ( - + - + router.back()} @@ -540,36 +541,3 @@ export default function StudioCalendarSettingsScreen() { ); } - -const styles = StyleSheet.create({ - screen: { - flex: 1, - }, - content: { - paddingHorizontal: BrandSpacing.lg, - paddingBottom: BrandSpacing.xxl + BrandSpacing.xxl + BrandSpacing.xxl + BrandSpacing.md, - gap: BrandSpacing.lg, - }, - connectionList: { - borderRadius: BrandRadius.card, - overflow: "hidden", - }, - feedbackCard: { - borderWidth: 1, - borderRadius: BrandRadius.input, - paddingHorizontal: BrandSpacing.md, - paddingVertical: BrandSpacing.md, - }, - feedbackText: { - ...BrandType.body, - }, - actionStack: { - gap: BrandSpacing.sm + 2, - }, - footerAction: { - position: "absolute", - left: BrandSpacing.lg, - right: BrandSpacing.lg, - bottom: BrandSpacing.lg, - }, -}); diff --git a/src/app/(app)/(studio-tabs)/studio/profile/index.tsx b/src/app/(app)/(studio-tabs)/studio/profile/index.tsx index 8e63d2f..6d31836 100644 --- a/src/app/(app)/(studio-tabs)/studio/profile/index.tsx +++ b/src/app/(app)/(studio-tabs)/studio/profile/index.tsx @@ -103,7 +103,7 @@ export default function StudioProfileScreen() { const [autoAcceptDefault, setAutoAcceptDefault] = useState(false); const [isSavingAutoAcceptDefault, setIsSavingAutoAcceptDefault] = useState(false); const [autoExpireMinutesBefore, setAutoExpireMinutesBefore] = useState(undefined); - const [, setIsSavingAutoExpireMinutes] = useState(false); + const [isSavingAutoExpireMinutes, setIsSavingAutoExpireMinutes] = useState(false); useEffect(() => { if (studioSettings) { diff --git a/src/app/(auth)/sign-in-screen.tsx b/src/app/(auth)/sign-in-screen.tsx index be1b995..03d6a55 100644 --- a/src/app/(auth)/sign-in-screen.tsx +++ b/src/app/(auth)/sign-in-screen.tsx @@ -14,7 +14,7 @@ import { ActionButton } from "@/components/ui/action-button"; import { IconButton } from "@/components/ui/icon-button"; import { KitTextField } from "@/components/ui/kit/kit-text-field"; import { SheetHeaderBlock } from "@/components/ui/sheet-header-block"; -import { type BrandPalette, BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; +import { type BrandPalette, BrandSpacing, BrandType } from "@/constants/brand"; import { useBrand } from "@/hooks/use-brand"; type Step = "email" | "code"; @@ -46,16 +46,12 @@ function MessageBanner({ return ( - - + + - - + + { @@ -294,7 +291,7 @@ export default function SignInScreen() { size="lg" /> - + { @@ -309,26 +306,22 @@ export default function SignInScreen() { - - + + {t("auth.or")} - + - + } @@ -348,12 +341,12 @@ export default function SignInScreen() { ) : ( - + {normalizedEmail} @@ -370,8 +363,8 @@ export default function SignInScreen() { placeholder="123456" style={styles.codeInput} /> - - + + { @@ -383,7 +376,7 @@ export default function SignInScreen() { size="lg" /> - + { @@ -404,7 +397,7 @@ export default function SignInScreen() { )} - + {infoMessage ? ( ) : null} @@ -419,11 +412,6 @@ export default function SignInScreen() { } const styles = StyleSheet.create({ - screen: { - flex: 1, - justifyContent: "space-between", - gap: BrandSpacing.xl, - }, emailInput: { ...BrandType.bodyMedium, includeFontPadding: false, @@ -437,27 +425,4 @@ const styles = StyleSheet.create({ includeFontPadding: false, fontVariant: ["tabular-nums"], }, - actionRow: { - flexDirection: "row", - gap: BrandSpacing.sm + 2, - }, - rowAction: { - flex: 1, - }, - dividerRow: { - flexDirection: "row", - alignItems: "center", - gap: BrandSpacing.sm, - paddingVertical: BrandSpacing.xs + 2, - }, - dividerLine: { - flex: 1, - height: 1, - }, - providerRow: { - flexDirection: "row", - alignItems: "stretch", - justifyContent: "center", - gap: BrandSpacing.componentPadding, - }, }); diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 88068ab..5425b86 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -1,4 +1,5 @@ import "@/global.css"; +import { BrandSpacing } from "@/constants/brand"; import { ConvexAuthProvider } from "@convex-dev/auth/react"; import MaterialIcons from "@expo/vector-icons/MaterialIcons"; import { BarlowCondensed_800ExtraBold } from "@expo-google-fonts/barlow-condensed"; @@ -8,7 +9,6 @@ import { Rubik_600SemiBold, Rubik_700Bold, } from "@expo-google-fonts/rubik"; -import { BrandSpacing } from "@/constants/brand"; import { DarkTheme, DefaultTheme, @@ -20,7 +20,7 @@ import { Stack } from "expo-router"; import * as SecureStore from "expo-secure-store"; import { StatusBar } from "expo-status-bar"; import { useMemo } from "react"; -import { LogBox, Platform, StyleSheet, View } from "react-native"; +import { LogBox, Platform, View } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { configureReanimatedLogger, ReanimatedLogLevel } from "react-native-reanimated"; import { SafeAreaProvider } from "react-native-safe-area-context"; @@ -132,7 +132,7 @@ function RootLayoutContent() { if (!isConvexUrlConfigured || !convex) { return ( - + {i18n.t("errors.configuration.title")} {i18n.t("errors.configuration.body")} @@ -152,13 +152,13 @@ function RootLayoutContent() { const statusInsetColor = topInsetBackgroundColor ?? fallbackBackgroundColor; return ( - + - + ); } - -const styles = StyleSheet.create({ - root: { - flex: 1, - }, - stackContainer: { - flex: 1, - }, - errorContainer: { - flex: 1, - alignItems: "center", - justifyContent: "center", - gap: BrandSpacing.md, - paddingHorizontal: BrandSpacing.xl, - }, -}); diff --git a/src/app/modal.tsx b/src/app/modal.tsx index 87f1031..48bf035 100644 --- a/src/app/modal.tsx +++ b/src/app/modal.tsx @@ -1,33 +1,19 @@ import { Link } from "expo-router"; import { useTranslation } from "react-i18next"; -import { StyleSheet } from "react-native"; -import { BrandSpacing } from "@/constants/brand"; import { ThemedText } from "@/components/themed-text"; import { ThemedView } from "@/components/themed-view"; +import { BrandSpacing } from "@/constants/brand"; export default function ModalScreen() { const { t } = useTranslation(); return ( - + {t("modal.title")} - + {t("modal.goHome")} ); } - -const styles = StyleSheet.create({ - container: { - flex: 1, - alignItems: "center", - justifyContent: "center", - padding: BrandSpacing.lg, - }, - link: { - marginTop: BrandSpacing.componentPadding, - paddingVertical: BrandSpacing.componentPadding, - }, -}); diff --git a/src/components/home/home-agenda-widget.tsx b/src/components/home/home-agenda-widget.tsx index ac27b11..b592b78 100644 --- a/src/components/home/home-agenda-widget.tsx +++ b/src/components/home/home-agenda-widget.tsx @@ -8,7 +8,7 @@ import type { BrandPalette } from "@/constants/brand"; import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; import { toSportLabel } from "@/convex/constants"; -const TIME_WIDTH = 56; +const TIME_WIDTH = BrandSpacing.iconContainerLarge; // 78px - equal to icon container large for alignment type AgendaItem = { id: string; diff --git a/src/components/home/home-header-sheet.tsx b/src/components/home/home-header-sheet.tsx index 95f0aca..30ee527 100644 --- a/src/components/home/home-header-sheet.tsx +++ b/src/components/home/home-header-sheet.tsx @@ -6,10 +6,10 @@ import { ProfileAvatar } from "@/components/ui/profile-avatar"; import type { BrandPalette } from "@/constants/brand"; import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; -const SHEET_EXPANDED_CONTENT_HEIGHT = 84; +const SHEET_EXPANDED_CONTENT_HEIGHT = BrandSpacing.iconContainerLarge + BrandSpacing.lg; // 78 + 16 = 94 for expanded content area const SHEET_CONTENT_GAP = BrandSpacing.sm; -const AVATAR_SIZE = 68; -const BADGE_SIZE = 22; +const AVATAR_SIZE = BrandSpacing.iconContainerLarge; // 78px - large avatar for header +const BADGE_SIZE = BrandSpacing.lg; // 24px - appropriate badge size export function getHomeHeaderExpandedHeight(safeTop: number) { return safeTop + SHEET_EXPANDED_CONTENT_HEIGHT; diff --git a/src/components/jobs/instructor/instructor-job-card.tsx b/src/components/jobs/instructor/instructor-job-card.tsx index 0cc4918..dd1546c 100644 --- a/src/components/jobs/instructor/instructor-job-card.tsx +++ b/src/components/jobs/instructor/instructor-job-card.tsx @@ -7,7 +7,8 @@ import { KitSurface } from "@/components/ui/kit"; import type { BrandPalette } from "@/constants/brand"; import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; -const IMAGE_PANEL_WIDTH_PERCENT = "44%"; +// Image panel takes 44% on mobile, responsive adjustment handled via layout breakpoint +const IMAGE_PANEL_WIDTH_PERCENT = "44%"; // Keep as percent for fluid layout import { getZoneLabel } from "@/constants/zones"; import type { Id } from "@/convex/_generated/dataModel"; import { toSportLabel } from "@/convex/constants"; diff --git a/src/components/jobs/studio/studio-jobs-list-parts.tsx b/src/components/jobs/studio/studio-jobs-list-parts.tsx index 5d39841..efab556 100644 --- a/src/components/jobs/studio/studio-jobs-list-parts.tsx +++ b/src/components/jobs/studio/studio-jobs-list-parts.tsx @@ -7,9 +7,6 @@ import { ActionButton } from "@/components/ui/action-button"; import { IconSymbol } from "@/components/ui/icon-symbol"; import { KitSurface } from "@/components/ui/kit"; import { type BrandPalette, BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; - -const AVATAR_SIZE = 42; -const AVATAR_RADIUS = 16; import { getZoneLabel } from "@/constants/zones"; import type { Id } from "@/convex/_generated/dataModel"; import { toSportLabel } from "@/convex/constants"; @@ -27,6 +24,9 @@ import type { PaymentStatus, PayoutStatus } from "@/lib/payments-utils"; import { appStatusDot, paymentDotColor } from "./studio-jobs-list.helpers"; import type { StudioJob, StudioJobApplication } from "./studio-jobs-list.types"; +const AVATAR_SIZE = BrandSpacing.xxl + BrandSpacing.xxl + 2; +const AVATAR_RADIUS = BrandRadius.card; + const PAYMENT_STATUS_KEY: Record = { created: "jobsTab.checkout.paymentStatus.created", pending: "jobsTab.checkout.paymentStatus.pending", diff --git a/src/components/loading-screen.tsx b/src/components/loading-screen.tsx index ce35278..b531c1d 100644 --- a/src/components/loading-screen.tsx +++ b/src/components/loading-screen.tsx @@ -4,15 +4,16 @@ import Animated, { FadeIn, FadeInDown, FadeInUp } from "react-native-reanimated" import { ThemedText } from "@/components/themed-text"; import { AppSymbol } from "@/components/ui/app-symbol"; -import { BrandSpacing } from "@/constants/brand"; +import { BrandRadius, BrandSpacing } from "@/constants/brand"; import { useBrand } from "@/hooks/use-brand"; -const LAUNCH_ICON_SIZE = 236; -const LAUNCH_ICON_RADIUS = 118; -const LAUNCH_INNER_SIZE = 112; -const LAUNCH_INNER_RADIUS = 36; -const LAUNCH_SYMBOL_WRAPPER_SIZE = 84; -const LAUNCH_SYMBOL_WRAPPER_RADIUS = 28; +// Loading screen uses a large centered launch icon with concentric rings +const LAUNCH_ICON_SIZE = BrandSpacing.haloSize; // 180px - matches brand halo size for visual impact +const LAUNCH_ICON_RADIUS = LAUNCH_ICON_SIZE / 2; // 90px +const LAUNCH_INNER_SIZE = BrandSpacing.iconContainerLarge + BrandSpacing.xl; // 78 + 24 = 102px +const LAUNCH_INNER_RADIUS = BrandRadius.card; // 24px - matches card radius +const LAUNCH_SYMBOL_WRAPPER_SIZE = BrandSpacing.iconContainerLarge; // 78px +const LAUNCH_SYMBOL_WRAPPER_RADIUS = BrandRadius.cardSubtle; // 18px type LoadingScreenProps = { variant?: "inline" | "launch"; diff --git a/src/components/map-tab/map-tab/map-web-command-panel.tsx b/src/components/map-tab/map-tab/map-web-command-panel.tsx index e6634d8..a72b1d9 100644 --- a/src/components/map-tab/map-tab/map-web-command-panel.tsx +++ b/src/components/map-tab/map-tab/map-web-command-panel.tsx @@ -3,15 +3,16 @@ import { Pressable, ScrollView, Text, View } from "react-native"; import { IconSymbol } from "@/components/ui/icon-symbol"; import { NativeSearchField } from "@/components/ui/native-search-field"; -import { type BrandPalette, BrandSpacing, BrandType } from "@/constants/brand"; +import { type BrandPalette, BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; import type { ZoneOption } from "@/constants/zones"; -const PANEL_WIDTH = 360; -const PANEL_RADIUS = 34; -const INNER_RADIUS = 24; -const METRIC_RADIUS = 18; -const TERRITORY_RADIUS = 22; -const ZONE_SELECT_RADIUS = 20; +// Map web command panel - desktop-focused with fixed width panel +const PANEL_WIDTH = BrandSpacing.iconContainer * 9 + BrandSpacing.xl; // ~360px responsive width +const PANEL_RADIUS = BrandRadius.card + BrandSpacing.sm; // 24 + 8 = 32px - between card and cardSubtle +const INNER_RADIUS = BrandRadius.cardSubtle; // 18px +const METRIC_RADIUS = BrandRadius.cardSubtle - BrandSpacing.xs; // 18 - 4 = 14px +const TERRITORY_RADIUS = BrandRadius.cardSubtle - BrandSpacing.xs; // 18 - 4 = 14px +const ZONE_SELECT_RADIUS = BrandRadius.cardSubtle - BrandSpacing.xs; // 18 - 4 = 14px type MapWebCommandPanelProps = { t: TFunction; diff --git a/src/components/map-tab/map-tab/map-web-header-panels.tsx b/src/components/map-tab/map-tab/map-web-header-panels.tsx index c2cd0fa..1272fa1 100644 --- a/src/components/map-tab/map-tab/map-web-header-panels.tsx +++ b/src/components/map-tab/map-tab/map-web-header-panels.tsx @@ -2,10 +2,11 @@ import type { TFunction } from "i18next"; import { Text, View } from "react-native"; import { ActionButton } from "@/components/ui/action-button"; -import { type BrandPalette, BrandSpacing, BrandType } from "@/constants/brand"; +import { type BrandPalette, BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; -const PANEL_RADIUS = 30; -const INNER_RADIUS = 18; +// Map web header panels - shares radii with command panel +const PANEL_RADIUS = BrandRadius.card + BrandSpacing.xs; // 24 + 4 = 28px +const INNER_RADIUS = BrandRadius.cardSubtle; // 18px type MapWebHeaderPanelsProps = { t: TFunction; diff --git a/src/components/maps/queue-map.native.tsx b/src/components/maps/queue-map.native.tsx index e754985..0123c95 100644 --- a/src/components/maps/queue-map.native.tsx +++ b/src/components/maps/queue-map.native.tsx @@ -12,11 +12,12 @@ import { getZoneIndexEntry, ISRAEL_MAP_INTERACTION_BOUNDS } from "@/constants/zo import { useBrand } from "@/hooks/use-brand"; import { useThemePreference } from "@/hooks/use-theme-preference"; -const GPS_BUTTON_SIZE = 58; -const GPS_ICON_SIZE = 20; -const ATTRIBUTION_SIZE = 34; -const LOADING_ICON_SIZE = 44; -const LOADING_ICON_RADIUS = 22; +// Map native controls - GPS and attribution buttons +const GPS_BUTTON_SIZE = BrandSpacing.iconContainer + BrandSpacing.lg; // 38 + 16 = 54px +const GPS_ICON_SIZE = BrandSpacing.md + BrandSpacing.xs; // 12 + 4 = 16px +const ATTRIBUTION_SIZE = BrandSpacing.iconContainer - BrandSpacing.xs; // 38 - 4 = 34px +const LOADING_ICON_SIZE = BrandSpacing.iconContainer + BrandSpacing.sm; // 38 + 8 = 46px +const LOADING_ICON_RADIUS = LOADING_ICON_SIZE / 2; // 23px import { ActionButton } from "../ui/action-button"; import { IconSymbol } from "../ui/icon-symbol"; import { KitSurface } from "../ui/kit"; diff --git a/src/components/maps/queue-map.web.tsx b/src/components/maps/queue-map.web.tsx index 6c5c382..0cb3b8c 100644 --- a/src/components/maps/queue-map.web.tsx +++ b/src/components/maps/queue-map.web.tsx @@ -8,9 +8,10 @@ import { useBrand } from "@/hooks/use-brand"; import type { QueueMapProps } from "./queue-map.types"; import { buildCoverageNodes, getResponseLabel, getZone } from "./queue-map.web.helpers"; -const MAP_RADIUS = 28; -const INNER_RADIUS = 22; -const MAP_MIN_HEIGHT = 320; +// Map web - desktop-focused map display with placeholder grid pattern +const MAP_RADIUS = BrandRadius.card + BrandSpacing.xs; // 24 + 4 = 28px +const INNER_RADIUS = BrandRadius.cardSubtle + BrandSpacing.xs; // 18 + 4 = 22px +const MAP_MIN_HEIGHT = BrandSpacing.mapMinHeight + BrandSpacing.lg; // 300 + 16 = 320px export function QueueMap(props: QueueMapProps) { const { t, i18n } = useTranslation(); diff --git a/src/components/profile/profile-tab/profile-mobile-hero.tsx b/src/components/profile/profile-tab/profile-mobile-hero.tsx index 15f4c0f..011f6c0 100644 --- a/src/components/profile/profile-tab/profile-mobile-hero.tsx +++ b/src/components/profile/profile-tab/profile-mobile-hero.tsx @@ -8,9 +8,9 @@ import { ProfileAvatar } from "@/components/ui/profile-avatar"; import type { BrandPalette } from "@/constants/brand"; import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; -const AVATAR_SIZE = 72; -const ICON_SIZE = 48; -const ICON_SYMBOL_SIZE = 21; +const AVATAR_SIZE = BrandSpacing.iconContainerLarge; // 78px - consistent with home-header-sheet +const ICON_SIZE = BrandSpacing.iconContainer + BrandSpacing.sm; // ~46px +const ICON_SYMBOL_SIZE = BrandSpacing.md + BrandSpacing.xs; // ~16px import { getActiveSocialCount, getProfileSummary, diff --git a/src/components/ui/action-button.tsx b/src/components/ui/action-button.tsx index 17e16eb..068aaa0 100644 --- a/src/components/ui/action-button.tsx +++ b/src/components/ui/action-button.tsx @@ -2,15 +2,16 @@ import type { ReactNode } from "react"; import { ActivityIndicator, I18nManager, Pressable, Text, View } from "react-native"; import { MeshGradientView } from "@/components/ui/kit"; import type { BrandPalette } from "@/constants/brand"; -import { BrandRadius, BrandType } from "@/constants/brand"; +import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; type ActionButtonTone = "primary" | "secondary"; type ActionButtonShape = "pill" | "square"; type ActionButtonSize = "md" | "lg"; -const BUTTON_HEIGHT_LG = 54; -const BUTTON_HEIGHT_MD = 42; -const BUTTON_MIN_WIDTH = 96; +// Button heights follow the spacing scale for consistency +const BUTTON_HEIGHT_LG = BrandSpacing.iconContainer + BrandSpacing.md; // 38 + 12 = 50px +const BUTTON_HEIGHT_MD = BrandSpacing.iconContainer + BrandSpacing.xs; // 38 + 4 = 42px +const BUTTON_MIN_WIDTH = BrandSpacing.iconContainer * 2 + BrandSpacing.sm; // 38*2 + 8 = 84px type ActionButtonProps = { label?: string; diff --git a/src/components/ui/kit/kit-button-group.tsx b/src/components/ui/kit/kit-button-group.tsx index ec2d0a3..10d24af 100644 --- a/src/components/ui/kit/kit-button-group.tsx +++ b/src/components/ui/kit/kit-button-group.tsx @@ -83,13 +83,14 @@ export function KitButtonGroup({ accessible className="overflow-hidden" style={[ - styles.group, { width: fullWidth ? "100%" : width, maxWidth, alignSelf: fullWidth ? "stretch" : alignSelfMap[align], backgroundColor: resolvedGroupBg, flexWrap: wraps ? "wrap" : "nowrap", + borderRadius: BrandRadius.button, + padding: BrandSpacing.sm - 2, }, style, ]} @@ -101,8 +102,8 @@ export function KitButtonGroup({ return ( ({ {showDivider ? ( ({ {selected ? ( ({ triggerSelectionHaptic(); onChange(option.value); }} + className="w-full" style={({ pressed }) => [ - styles.segmentPressable, { opacity: option.disabled ? 0.45 : pressed ? 0.9 : 1, }, @@ -165,15 +169,17 @@ export function KitButtonGroup({ }, ]} > - {option.icon ? {option.icon} : null} + {option.icon ? {option.icon} : null} {option.label} @@ -191,50 +197,3 @@ const alignSelfMap: Record [ { minHeight: BrandSpacing.iconContainer, - borderRadius: BrandRadius.buttonSubtle, - borderCurve: "continuous", - alignItems: "center", - justifyContent: "center", paddingHorizontal: BrandSpacing.componentPadding, paddingVertical: BrandSpacing.sm, backgroundColor: selected ? (palette.primary as string) : (palette.surfaceAlt as string), opacity: disabled ? 0.72 : 1, transform: [{ scale: pressed && !disabled ? 0.985 : 1 }], + borderRadius: BrandRadius.buttonSubtle, }, style, ]} diff --git a/src/components/ui/kit/kit-disclosure-button-group.tsx b/src/components/ui/kit/kit-disclosure-button-group.tsx index 4bf7d48..0bf06cf 100644 --- a/src/components/ui/kit/kit-disclosure-button-group.tsx +++ b/src/components/ui/kit/kit-disclosure-button-group.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import type { TextStyle, ViewStyle } from "react-native"; +import type { ViewStyle } from "react-native"; import { Pressable, StyleSheet, Text, View } from "react-native"; import Animated, { FadeInRight, @@ -88,12 +88,11 @@ export function KitDisclosureButtonGroup({ layout={DISCLOSURE_LAYOUT} className="overflow-hidden" style={[ - styles.rail, { backgroundColor: resolvedRailColor, minHeight: metrics.minHeight, - borderRadius: metrics.railRadius, padding: metrics.railPadding, + borderRadius: BrandRadius.button, }, style, ]} @@ -108,13 +107,16 @@ export function KitDisclosureButtonGroup({ {options.map((option, index) => { const selected = option.value === value; return ( - + {index > 0 ? ( ({ {selected ? ( @@ -146,7 +144,8 @@ export function KitDisclosureButtonGroup({ triggerSelectionHaptic(); onChange(option.value); }} - style={({ pressed }) => [styles.segmentButton, { opacity: pressed ? 0.9 : 1 }]} + className="relative z-10" + style={({ pressed }) => [{ opacity: pressed ? 0.9 : 1 }]} > ({ } satisfies ViewStyle, ]} > - {option.icon ? {option.icon} : null} + {option.icon ? {option.icon} : null} {option.label} @@ -185,26 +187,26 @@ export function KitDisclosureButtonGroup({ triggerSelectionHaptic(); onToggleExpanded(); }} + className="justify-center" style={({ pressed }) => [ - styles.segmentWrap, - styles.triggerPressable, - { opacity: pressed ? 0.92 : 1 }, + { + opacity: pressed ? 0.92 : 1, + }, ]} > - {triggerIcon ? {triggerIcon} : null} + {triggerIcon ? {triggerIcon} : null} {triggerLabel ? ( - + {triggerLabel} ) : null} @@ -213,58 +215,3 @@ export function KitDisclosureButtonGroup({ ); } - -const styles = StyleSheet.create({ - rail: { - borderCurve: "continuous", - flexDirection: "row", - alignItems: "stretch", - overflow: "hidden", - }, - optionsRow: { - flexDirection: "row", - alignItems: "stretch", - }, - segmentWrap: { - position: "relative", - }, - divider: { - position: "absolute", - left: 0, - width: StyleSheet.hairlineWidth, - opacity: 0.5, - }, - selectionFill: { - position: "absolute", - }, - segmentButton: { - position: "relative", - zIndex: 1, - }, - segmentContent: { - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - gap: BrandSpacing.xs + 2, - }, - triggerPressable: { - justifyContent: "center", - }, - triggerButton: { - alignItems: "center", - justifyContent: "center", - flexDirection: "row", - gap: BrandSpacing.xs + 2, - }, - iconWrap: { - alignItems: "center", - justifyContent: "center", - }, - segmentLabel: { - ...BrandType.bodyMedium, - fontWeight: "700", - includeFontPadding: false, - textAlign: "center", - textAlignVertical: "center", - }, -}); diff --git a/src/components/ui/kit/kit-segmented-toggle.tsx b/src/components/ui/kit/kit-segmented-toggle.tsx index 248515f..11946d7 100644 --- a/src/components/ui/kit/kit-segmented-toggle.tsx +++ b/src/components/ui/kit/kit-segmented-toggle.tsx @@ -27,9 +27,12 @@ export function KitSegmentedToggle({ return ( ) : null} Date: Mon, 23 Mar 2026 18:41:12 +0200 Subject: [PATCH 04/44] Migrate NativeWind styling system and harden calendar sync --- biome.json | 8 + bun.lock | 65 +-- convex/calendarNode.ts | 14 +- docs/nativewind-migration-clusters.md | 69 ++++ docs/nativewind-token-system.md | 137 +++++++ metro.config.js | 2 +- package.json | 8 +- scripts/android/start-expo-linux.sh | 5 +- scripts/check-no-hardcoded-styles.mjs | 85 ++++ .../instructor/jobs/studios/[studioId].tsx | 56 +-- .../instructor/profile/calendar-settings.tsx | 134 +++--- .../profile/identity-verification.tsx | 107 ++--- .../instructor/profile/index.tsx | 2 +- .../instructor/profile/location.tsx | 208 ++++------ .../instructor/profile/payments.tsx | 387 +++++++++--------- .../instructor/profile/sports.tsx | 117 ++---- .../studio/profile/calendar-settings.tsx | 54 ++- .../(studio-tabs)/studio/profile/index.tsx | 64 +-- .../(studio-tabs)/studio/profile/payments.tsx | 25 +- src/app/(auth)/sign-in-screen.tsx | 27 +- src/app/_layout.tsx | 10 +- .../calendar/calendar-controller-helpers.ts | 1 + .../calendar-tab/calendar-date-utils.ts | 59 ++- .../calendar/calendar-tab/index.tsx | 18 +- .../calendar/use-calendar-tab-controller.ts | 9 +- src/components/home/home-agenda-widget.tsx | 33 +- src/components/home/home-dashboard-layout.tsx | 11 +- src/components/home/home-header-sheet.tsx | 24 +- src/components/home/home-shared.tsx | 22 +- .../home/instructor-home-content.tsx | 18 +- src/components/home/studio-home-content.tsx | 44 +- src/components/jobs/instructor-feed.tsx | 30 +- .../jobs/instructor/instructor-job-card.tsx | 44 +- .../jobs/jobs-tab/jobs-section-header.tsx | 3 +- src/components/jobs/notice-banner.tsx | 51 ++- src/components/jobs/studio-feed.tsx | 14 +- .../jobs/studio/create-job-sheet-sections.tsx | 79 ++-- .../jobs/studio/create-job-sheet.tsx | 36 +- .../jobs/studio/studio-jobs-list-parts.tsx | 145 ++----- .../jobs/studio/studio-jobs-top-sheet.tsx | 18 +- src/components/loading-screen.tsx | 34 +- .../map-tab/map-tab/map-sheet-header.tsx | 4 +- .../map-tab/map-tab/map-web-command-panel.tsx | 67 +-- .../map-tab/map-tab/map-web-header-panels.tsx | 36 +- .../map-tab/map-tab/map-web-workbench.tsx | 16 +- .../map-tab/map/map-selected-zones-strip.tsx | 13 +- .../map-tab/map/map-sheet-results.tsx | 10 +- src/components/maps/queue-map.native.tsx | 45 +- src/components/maps/queue-map.web.tsx | 100 ++--- .../payments/payment-activity-list.tsx | 46 +-- .../profile/calendar-connection-row.tsx | 16 +- .../profile-editor/profile-editor-actions.tsx | 3 +- .../profile-editor-identity-panel.tsx | 12 +- .../profile-editor-social-panel.tsx | 18 +- .../profile/profile-settings-sections.tsx | 64 +-- .../profile/profile-social-links.tsx | 7 +- .../profile/profile-subpage-sheet.tsx | 51 +-- .../profile-desktop-hero-panel.tsx | 22 +- .../profile-tab/profile-mobile-hero.tsx | 34 +- .../profile/sports-multi-select.tsx | 62 +-- src/components/profile/status-signal.tsx | 14 +- src/components/ui/kit/kit-button-group.tsx | 47 ++- src/components/ui/kit/kit-chip.tsx | 8 +- .../ui/kit/kit-disclosure-button-group.tsx | 15 +- src/components/ui/kit/kit-mesh-gradient.tsx | 23 +- .../ui/kit/kit-social-icon-button.tsx | 10 +- src/components/ui/native-search-field.tsx | 25 +- src/components/ui/sheet-header-block.tsx | 26 +- src/constants/brand.ts | 36 +- src/global.css | 80 ++-- src/i18n/translations/en.ts | 14 +- src/i18n/translations/he.ts | 23 +- src/lib/device-calendar-sync.ts | 5 +- .../navigation/role-tabs-layout.web.tsx | 101 ++--- src/tw/image.tsx | 16 +- 75 files changed, 1735 insertions(+), 1611 deletions(-) create mode 100644 docs/nativewind-migration-clusters.md create mode 100644 docs/nativewind-token-system.md create mode 100644 scripts/check-no-hardcoded-styles.mjs diff --git a/biome.json b/biome.json index 61978e7..e4b7e4b 100644 --- a/biome.json +++ b/biome.json @@ -63,5 +63,13 @@ "formatter": { "trailingCommas": "none" } + }, + "css": { + "parser": { + "tailwindDirectives": true + }, + "formatter": { + "enabled": true + } } } diff --git a/bun.lock b/bun.lock index 1258557..70cee3f 100644 --- a/bun.lock +++ b/bun.lock @@ -50,12 +50,12 @@ "expo-web-browser": "~55.0.10", "geojson": "^0.5.0", "i18next": "^25.9.0", - "nativewind": "5.0.0-preview.2", + "nativewind": "5.0.0-preview.3", "react": "19.2.0", "react-dom": "19.2.0", "react-i18next": "^16.5.8", "react-native": "0.83.2", - "react-native-css": "0.0.0-nightly.5ce6396", + "react-native-css": "3.0.6", "react-native-gesture-handler": "~2.30.0", "react-native-reanimated": "4.2.1", "react-native-safe-area-context": "~5.6.2", @@ -79,6 +79,9 @@ }, }, }, + "overrides": { + "lightningcss": "1.30.1", + }, "packages": { "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], @@ -704,6 +707,8 @@ "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], + "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], "@types/graceful-fs": ["@types/graceful-fs@4.1.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ=="], @@ -716,6 +721,8 @@ "@types/istanbul-reports": ["@types/istanbul-reports@3.0.4", "", { "dependencies": { "@types/istanbul-lib-report": "*" } }, "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ=="], + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], @@ -1296,29 +1303,27 @@ "lighthouse-logger": ["lighthouse-logger@1.4.2", "", { "dependencies": { "debug": "^2.6.9", "marky": "^1.2.2" } }, "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g=="], - "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + "lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="], - "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="], - "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="], - "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig=="], - "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.1", "", { "os": "linux", "cpu": "arm" }, "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q=="], - "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw=="], - "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ=="], - "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw=="], - "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ=="], - "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA=="], - "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], - - "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="], "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], @@ -1410,7 +1415,7 @@ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - "nativewind": ["nativewind@5.0.0-preview.2", "", { "dependencies": { "tailwindcss-safe-area": "^1.1.0" }, "peerDependencies": { "react-native-css": "^3.0.1", "tailwindcss": ">4.1.11" } }, "sha512-rTNrwFIwl/n2VH7KPvsZj/NdvKf+uGHF4NYtPamr5qG2eTYGT8B8VeyCPfYf/xUskpWOLJVqVEXaFO/vuIDEdw=="], + "nativewind": ["nativewind@5.0.0-preview.3", "", { "dependencies": { "tailwindcss-safe-area": "^1.1.0" }, "peerDependencies": { "react-native-css": "^3.0.1", "tailwindcss": ">4.1.11" } }, "sha512-cbl1GzzY55NL2IG35AaAVfrL4+bCh77sa39aST5/o7xy3TLPthAtzhNPstnrCn+DtIglTnXOlOJGFrX2WdhI6w=="], "negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="], @@ -1548,7 +1553,7 @@ "react-native": ["react-native@0.83.2", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.83.2", "@react-native/codegen": "0.83.2", "@react-native/community-cli-plugin": "0.83.2", "@react-native/gradle-plugin": "0.83.2", "@react-native/js-polyfills": "0.83.2", "@react-native/normalize-colors": "0.83.2", "@react-native/virtualized-lists": "0.83.2", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.32.0", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "hermes-compiler": "0.14.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.83.3", "metro-source-map": "^0.83.3", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.27.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.1.1", "react": "^19.2.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-ZDma3SLkRN2U2dg0/EZqxNBAx4of/oTnPjXAQi299VLq2gdnbZowGy9hzqv+O7sTA62g+lM1v+2FM5DUnJ/6hg=="], - "react-native-css": ["react-native-css@0.0.0-nightly.5ce6396", "", { "dependencies": { "@expo/metro-runtime": "~6.1.1", "babel-plugin-react-compiler": "^19.1.0-rc.2", "colorjs.io": "0.6.0-alpha.1", "comment-json": "^4.2.5", "debug": "^4.4.1" }, "peerDependencies": { "expo": "54.0.0-preview.6", "lightningcss": ">=1.27.0", "react": "19.1.0", "react-native": "0.81.0" } }, "sha512-jiSNSRpf5h2j1yS5vtOps8iZmZ2+GIM8YYZQrzcZsIaO8QDKdseZSGmxaeZ0cLy2tYCNKJH+1RyHdWMcvSouNg=="], + "react-native-css": ["react-native-css@3.0.6", "", { "dependencies": { "@types/debug": "^4.1.12", "babel-plugin-react-compiler": "^19.1.0-rc.2", "colorjs.io": "0.6.0-alpha.1", "comment-json": "^4.2.5", "debug": "^4.4.1" }, "peerDependencies": { "@expo/metro-config": ">=54", "lightningcss": ">=1.27.0", "react": ">=19", "react-native": ">=0.81" } }, "sha512-YhP3SGc9VRItWZ9TMt+QENor5ha2ym3z3QiyL/eRVrBmV3LCv9FyxOhw9c0SB8Ndvwc7KH0ch2XCQ7ZgZg5dNQ=="], "react-native-gesture-handler": ["react-native-gesture-handler@2.30.0", "", { "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-5YsnKHGa0X9C8lb5oCnKm0fLUPM6CRduvUUw2Bav4RIj/C3HcFh4RIUnF8wgG6JQWCL1//gRx4v+LVWgcIQdGA=="], @@ -1882,8 +1887,6 @@ "@expo/fingerprint/arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], - "@expo/metro-config/lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="], - "@expo/metro-config/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="], "@expo/package-manager/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="], @@ -2030,8 +2033,6 @@ "react-native/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], - "react-native-css/@expo/metro-runtime": ["@expo/metro-runtime@6.1.2", "", { "dependencies": { "anser": "^1.4.9", "pretty-format": "^29.7.0", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-dom": "*", "react-native": "*" }, "optionalPeers": ["react-dom"] }, "sha512-nvM+Qv45QH7pmYvP8JB1G8JpScrWND3KrMA6ZKe62cwwNiX/BjHU28Ear0v/4bQWXlOY0mv6B8CDIm8JxXde9g=="], - "react-native-css/babel-plugin-react-compiler": ["babel-plugin-react-compiler@19.1.0-rc.1-rc-af1b7da-20250421", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-E3kaokBhWDLf7ZD8fuYjYn0ZJHYZ+3EHtAWCdX2hl4lpu1z9S/Xr99sxhx2bTCVB41oIesz9FtM8f4INsrZaOw=="], "react-native-reanimated/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], @@ -2086,28 +2087,6 @@ "@expo/cli/ora/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="], - "@expo/metro-config/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], - - "@expo/metro-config/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="], - - "@expo/metro-config/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="], - - "@expo/metro-config/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="], - - "@expo/metro-config/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="], - - "@expo/metro-config/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="], - - "@expo/metro-config/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="], - - "@expo/metro-config/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="], - - "@expo/metro-config/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="], - - "@expo/metro-config/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="], - - "@expo/metro-config/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="], - "@expo/package-manager/ora/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], "@expo/package-manager/ora/cli-cursor": ["cli-cursor@2.1.0", "", { "dependencies": { "restore-cursor": "^2.0.0" } }, "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw=="], diff --git a/convex/calendarNode.ts b/convex/calendarNode.ts index abf3a6a..b6e7d98 100644 --- a/convex/calendarNode.ts +++ b/convex/calendarNode.ts @@ -51,6 +51,9 @@ type GoogleIntegrationRecord = { agendaSyncToken?: string; }; +const GOOGLE_REFRESH_CREDENTIALS_MISSING_ERROR = + "Google Calendar integration is missing refresh credentials"; + type CalendarOwnerProfile = { role: CalendarOwnerRole; calendarProvider: "none" | "google" | "apple"; @@ -345,7 +348,7 @@ async function getGoogleAccessToken(ctx: any, integration: GoogleIntegrationReco if (!accessToken || accessTokenExpiresAt < now + 60_000) { const refreshToken = decryptCalendarToken(integration.refreshToken); if (!refreshToken || !integration.oauthClientId) { - throw new ConvexError("Google Calendar integration is missing refresh credentials"); + throw new ConvexError(GOOGLE_REFRESH_CREDENTIALS_MISSING_ERROR); } const refreshed = await refreshGoogleAccessToken({ refreshToken, @@ -599,6 +602,15 @@ async function runGoogleCalendarSync( integrationId: integration._id, lastError: message, }); + if (message === GOOGLE_REFRESH_CREDENTIALS_MISSING_ERROR) { + return { + ok: true, + syncedCount: 0, + removedCount: 0, + importedCount: 0, + importedRemovedCount: 0, + }; + } throw error; } } diff --git a/docs/nativewind-migration-clusters.md b/docs/nativewind-migration-clusters.md new file mode 100644 index 0000000..07863e4 --- /dev/null +++ b/docs/nativewind-migration-clusters.md @@ -0,0 +1,69 @@ +# NativeWind Migration Clusters + +This document defines the migration split for parallel workers. + +## Cluster A: Profile Foundation + +- `src/components/profile/profile-settings-sections.tsx` +- `src/components/profile/status-signal.tsx` +- `src/components/profile/sports-multi-select.tsx` +- `src/components/profile/profile-subpage-sheet.tsx` + +Focus: +- Replace arithmetic spacing/radius with semantic tokens. +- Normalize shared profile cards, rows, and signal surfaces. + +## Cluster B: Instructor Profile Screens + +- `src/app/(app)/(instructor-tabs)/instructor/profile/location.tsx` +- `src/app/(app)/(instructor-tabs)/instructor/profile/sports.tsx` +- `src/app/(app)/(instructor-tabs)/instructor/profile/calendar-settings.tsx` + +Focus: +- Align controls and cards with the new radius system. +- Normalize repeated button / section spacing patterns. + +## Cluster C: Home Surfaces + +- `src/components/home/home-shared.tsx` +- `src/components/home/home-agenda-widget.tsx` +- `src/components/home/home-header-sheet.tsx` +- `src/components/home/studio-home-content.tsx` +- `src/components/home/instructor-home-content.tsx` + +Focus: +- Normalize dashboard card spacing and internal stacks. +- Reduce one-off chip/badge styling. + +## Cluster D: Maps and Command Panels + +- `src/components/maps/queue-map.web.tsx` +- `src/components/map-tab/map-tab/map-web-command-panel.tsx` +- `src/components/map-tab/map-tab/map-web-header-panels.tsx` +- `src/components/map-tab/map/map-sheet-results.tsx` + +Focus: +- Replace ad-hoc rounded values with `hard` / `medium` / `soft`. +- Reduce rgba usage where palette + opacity suffices. + +## Cluster E: Auth and Shell + +- `src/app/(auth)/sign-in-screen.tsx` +- `src/modules/navigation/role-tabs-layout.web.tsx` +- `src/components/loading-screen.tsx` +- `src/components/ui/sheet-header-block.tsx` + +Focus: +- Normalize shell-level surfaces and pills. +- Remove bracketed one-off classes where semantic tokens exist. + +## Cluster F: Jobs and Lists + +- `src/components/jobs/studio/studio-jobs-list-parts.tsx` +- `src/components/jobs/instructor/instructor-job-card.tsx` +- `src/app/(app)/(instructor-tabs)/instructor/jobs/studios/[studioId].tsx` +- `src/components/payments/payment-activity-list.tsx` + +Focus: +- Normalize rows, chips, badges, and card shells. +- Replace one-off chip/button radius and padding math. diff --git a/docs/nativewind-token-system.md b/docs/nativewind-token-system.md new file mode 100644 index 0000000..9b46535 --- /dev/null +++ b/docs/nativewind-token-system.md @@ -0,0 +1,137 @@ +# NativeWind Token System + +This worktree is standardizing styling around a small semantic token set rather than ad-hoc math and one-off values. + +## Goals + +- Reduce the number of styling decisions made per screen. +- Prefer semantic tokens over raw numbers. +- Keep the brand recognizable while making surfaces and controls feel more coherent. +- Preserve compatibility with existing `BrandSpacing`, `BrandRadius`, `BrandType`, and `useBrand()` consumers during migration. + +## Core Rules + +- Use semantic tokens first. +- Use raw scale tokens only when the semantic token is genuinely the wrong fit. +- Do not create new radius or spacing values by subtracting or adding arbitrary numbers unless the component is mathematically derived. +- Prefer NativeWind class tokens for layout, spacing, and rounding when the value is static. +- Keep dynamic colors and dynamic dimensions in `style` / `vars()`. + +## Radius System + +We are collapsing the app to four rounding styles: + +- `hard`: dense controls, chips, tags, segmented items +- `medium`: standard controls, inputs, inline cards +- `soft`: primary cards, sheets, hero surfaces +- `pill`: fully rounded badges, chips, avatars, capsules + +### Canonical Values + +- `hard = 12` +- `medium = 18` +- `soft = 24` +- `pill = 999` + +### Backwards Compatibility Mapping + +- `card -> soft` +- `cardSubtle -> medium` +- `button -> medium` +- `buttonSubtle -> hard` +- `input -> medium` +- `icon -> pill` +- `circle -> pill` + +## Spacing System + +Raw scale stays: + +- `xs = 4` +- `sm = 8` +- `md = 12` +- `lg = 16` +- `xl = 24` +- `xxl = 32` + +Semantic spacing should be preferred in component APIs and repeated layouts: + +- `stackTight = sm` +- `stack = md` +- `stackRoomy = lg` +- `stackLoose = xl` +- `insetTight = md` +- `inset = lg` +- `insetRoomy = xl` +- `section = xxl` +- `controlX = 14` +- `controlY = 12` + +## Size System + +Use a small set of semantic sizes for controls and feature surfaces: + +- `controlSm = 38` +- `controlMd = 44` +- `controlLg = 52` +- `iconSm = 18` +- `iconMd = 24` +- `iconLg = 32` +- `avatarSm = 38` +- `avatarMd = 48` +- `avatarLg = 78` + +Only keep custom dimensions when they are truly feature-specific, such as map heights or animation halos. + +## Color System + +Use only these categories: + +- `brand`: primary, primarySubtle, primaryPressed, secondary, onPrimary +- `surface`: appBg, surface, surfaceAlt, surfaceElevated +- `text`: text, textMuted, textMicro +- `border`: border, borderStrong +- `semantic`: success, successSubtle, warning, warningSubtle, danger, dangerSubtle +- `feature accents`: `calendar`, `payments`, `didit` only when the feature needs its own accent identity + +Do not introduce new ad-hoc rgba colors if an opacity on an existing token will work. + +## NativeWind Naming + +NativeWind theme tokens should expose: + +- `rounded-hard` +- `rounded-medium` +- `rounded-soft` +- `rounded-pill` + +Spacing and sizes should expose semantic names where we reuse them: + +- `gap-stack` +- `gap-stack-tight` +- `gap-stack-roomy` +- `px-inset` +- `px-inset-roomy` +- `min-h-control-sm` +- `min-h-control-md` +- `min-h-control-lg` + +Raw scale utilities like `px-md` and `gap-lg` still exist, but semantic aliases are preferred for shared components. + +## Migration Priority + +1. Shared UI kit and profile scaffolding +2. Auth and onboarding surfaces +3. Profile screens with heavy style debt +4. Map / web shell surfaces +5. Home dashboards and list cards + +## Review Standard + +Every migrated file should answer yes to these: + +- Are radius choices one of `hard`, `medium`, `soft`, or `pill`? +- Are repeated paddings and gaps using semantic tokens or the core scale? +- Are colors sourced from the palette or a defined feature accent? +- Is there less arithmetic in styles than before? +- Would another screen make the same styling decision by default? diff --git a/metro.config.js b/metro.config.js index 526d088..6dd7f4b 100644 --- a/metro.config.js +++ b/metro.config.js @@ -62,5 +62,5 @@ config.transformer.getTransformOptions = async () => ({ module.exports = withNativewind(config, { inlineVariables: false, - globalClassNamePolyfill: false, + globalClassNamePolyfill: true, }); diff --git a/package.json b/package.json index b0b26fe..2769a1d 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "format": "biome format --write .", "format:check": "biome format .", "check": "biome check .", + "check:no-hardcoded-styles": "node ./scripts/check-no-hardcoded-styles.mjs", "audit:knip": "knip --config knip.json --include files,dependencies,unlisted,binaries", "knip:full": "knip --config knip.json", "knip:production": "knip --config knip.json --production --include files,dependencies,unlisted,binaries", @@ -87,12 +88,12 @@ "expo-web-browser": "~55.0.10", "geojson": "^0.5.0", "i18next": "^25.9.0", - "nativewind": "5.0.0-preview.2", + "nativewind": "5.0.0-preview.3", "react": "19.2.0", "react-dom": "19.2.0", "react-i18next": "^16.5.8", "react-native": "0.83.2", - "react-native-css": "0.0.0-nightly.5ce6396", + "react-native-css": "3.0.6", "react-native-gesture-handler": "~2.30.0", "react-native-reanimated": "4.2.1", "react-native-safe-area-context": "~5.6.2", @@ -125,5 +126,8 @@ "private": true, "resolutions": { "lightningcss": "1.30.1" + }, + "overrides": { + "lightningcss": "1.30.1" } } diff --git a/scripts/android/start-expo-linux.sh b/scripts/android/start-expo-linux.sh index 2aa293d..b09e39b 100755 --- a/scripts/android/start-expo-linux.sh +++ b/scripts/android/start-expo-linux.sh @@ -117,11 +117,12 @@ open_dev_client_when_ready() { if curl -fsS "http://127.0.0.1:$METRO_PORT" >/dev/null 2>&1; then DEV_URL_LOCAL="queue://expo-development-client/?url=http://127.0.0.1:$METRO_PORT" DEV_URL_EXP="exp+queue://expo-development-client/?url=http://127.0.0.1:$METRO_PORT" - for _ in $(seq 1 30); do + # Relaunch exactly once when Metro becomes reachable. + for _ in $(seq 1 1); do adb -s "$SERIAL" shell monkey -p "$APP_ID" -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 || true adb -s "$SERIAL" shell am start -a android.intent.action.VIEW -d "$DEV_URL_LOCAL" >/dev/null 2>&1 || true adb -s "$SERIAL" shell am start -a android.intent.action.VIEW -d "$DEV_URL_EXP" >/dev/null 2>&1 || true - sleep 2 + sleep 3 done return 0 fi diff --git a/scripts/check-no-hardcoded-styles.mjs b/scripts/check-no-hardcoded-styles.mjs new file mode 100644 index 0000000..0fd473c --- /dev/null +++ b/scripts/check-no-hardcoded-styles.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; + +const projectRoot = process.cwd(); +const srcRoot = path.join(projectRoot, "src"); + +const allowlist = new Set([ + path.join(srcRoot, "constants", "brand.ts"), + path.join(srcRoot, "global.css"), +]); + +const fileExtensions = new Set([".ts", ".tsx", ".js", ".jsx", ".css"]); + +const violations = [ + { + name: "hex color", + regex: /#[0-9A-Fa-f]{3,8}\b/g, + }, + { + name: "rgb/rgba color", + regex: /\brgba?\(/g, + }, + { + name: "bracket utility escape", + regex: /\b(?:rounded|px|py|p|mx|my|m|gap|size|w|h)-\[[^\]]+\]/g, + }, + { + name: "raw borderRadius", + regex: /\bborderRadius:\s*[0-9]+/g, + }, + { + name: "raw paddingHorizontal", + regex: /\bpaddingHorizontal:\s*[0-9]+/g, + }, + { + name: "raw paddingVertical", + regex: /\bpaddingVertical:\s*[0-9]+/g, + }, + { + name: "raw marginTop", + regex: /\bmarginTop:\s*[0-9]+/g, + }, + { + name: "raw marginBottom", + regex: /\bmarginBottom:\s*[0-9]+/g, + }, + { + name: "raw gap", + regex: /\bgap:\s*[0-9]+/g, + }, +]; + +let hasViolation = false; + +function walk(dir) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "archive") continue; + walk(fullPath); + continue; + } + if (!fileExtensions.has(path.extname(entry.name))) continue; + if (entry.name.includes(".archive.")) continue; + if (allowlist.has(fullPath)) continue; + + const content = fs.readFileSync(fullPath, "utf8"); + const lines = content.split("\n"); + for (const rule of violations) { + for (const match of content.matchAll(rule.regex)) { + const before = content.slice(0, match.index); + const line = before.split("\n").length; + lines[line - 1] ??= ""; + console.log(`${path.relative(projectRoot, fullPath)}:${line}: ${rule.name}: ${match[0]}`); + hasViolation = true; + break; + } + } + } +} + +walk(srcRoot); +process.exitCode = hasViolation ? 1 : 0; diff --git a/src/app/(app)/(instructor-tabs)/instructor/jobs/studios/[studioId].tsx b/src/app/(app)/(instructor-tabs)/instructor/jobs/studios/[studioId].tsx index daa9cc1..982b9e2 100644 --- a/src/app/(app)/(instructor-tabs)/instructor/jobs/studios/[studioId].tsx +++ b/src/app/(app)/(instructor-tabs)/instructor/jobs/studios/[studioId].tsx @@ -88,7 +88,8 @@ export default function InstructorStudioProfileRoute() { if (!studioProfile || !pathname?.startsWith("/instructor/jobs/studios/")) { return null; } - const headerHeight = BrandSpacing.iconContainer * 7 + BrandSpacing.componentPadding + BrandSpacing.xs; + const headerHeight = + BrandSpacing.iconContainer * 7 + BrandSpacing.componentPadding + BrandSpacing.xs; const availableHeight = Math.max(1, screenHeight - safeTop - 80); const collapsedStep = Math.max(0.24, Math.min(0.42, headerHeight / availableHeight)); @@ -99,8 +100,8 @@ export default function InstructorStudioProfileRoute() { height: headerHeight, justifyContent: "space-between", overflow: "hidden", - borderBottomLeftRadius: BrandRadius.cardSubtle + 10, - borderBottomRightRadius: BrandRadius.cardSubtle + 10, + borderBottomLeftRadius: BrandRadius.soft, + borderBottomRightRadius: BrandRadius.soft, borderCurve: "continuous", backgroundColor: palette.primary as string, }} @@ -118,14 +119,9 @@ export default function InstructorStudioProfileRoute() { }} /> ) : null} - + - + {sportsLabels.length > 0 || studioProfile.bio ? ( - + {sportsLabels.length > 0 ? ( - + {sportsLabels.map((label) => ( ) : null} - + {sortedJobs.map((job) => ( }).ca type GoogleCalendarStatus = { connected: boolean; + hasRefreshToken: boolean; accountEmail?: string | undefined; lastError?: string | undefined; }; @@ -143,7 +144,10 @@ export default function CalendarSettingsScreen() { } const hasGoogleConnection = Boolean(googleStatus?.connected); - const isGoogleConnected = provider === "google" && hasGoogleConnection; + const hasGoogleRefreshToken = Boolean(googleStatus?.hasRefreshToken); + const needsGoogleReconnect = hasGoogleConnection && !hasGoogleRefreshToken; + const canUseGoogleCalendar = hasGoogleConnection && hasGoogleRefreshToken; + const isGoogleConnected = provider === "google" && canUseGoogleCalendar; const isAppleConnected = provider === "apple"; const isBusy = isSaving || isConnectingGoogle || isDisconnectingGoogle || isSyncingGoogle; @@ -159,9 +163,7 @@ export default function CalendarSettingsScreen() { ...(instructorSettings.hourlyRateExpectation !== undefined ? { hourlyRateExpectation: instructorSettings.hourlyRateExpectation } : {}), - ...(instructorSettings.address !== undefined - ? { address: instructorSettings.address } - : {}), + ...(instructorSettings.address !== undefined ? { address: instructorSettings.address } : {}), ...(instructorSettings.latitude !== undefined ? { latitude: instructorSettings.latitude } : {}), @@ -328,6 +330,13 @@ export default function CalendarSettingsScreen() { }; const onSyncGoogleNow = async () => { + if (!canUseGoogleCalendar) { + Alert.alert( + t("profile.settings.errors.saveFailed"), + t("profile.settings.calendar.googleReconnectRequired"), + ); + return; + } setIsSyncingGoogle(true); try { await syncGoogleCalendar({}); @@ -431,18 +440,20 @@ export default function CalendarSettingsScreen() { }; return ( - + - + {googleStatus.lastError} ) : null} + {needsGoogleReconnect ? ( + + + {t("profile.settings.calendar.googleReconnectRequired")} + + + ) : null} + {googleConfigError ? ( - {googleConfigError} + {googleConfigError} ) : null} - {isGoogleConnected ? ( - + {provider === "google" ? ( + { void onSyncGoogleNow(); }} - disabled={isSyncingGoogle || isBusy} + disabled={!canUseGoogleCalendar || isSyncingGoogle || isBusy} palette={palette} fullWidth /> @@ -537,7 +566,7 @@ export default function CalendarSettingsScreen() { ) : null} - + router.back()} @@ -548,36 +577,3 @@ export default function CalendarSettingsScreen() { ); } - -const styles = StyleSheet.create({ - screen: { - flex: 1, - }, - content: { - paddingHorizontal: BrandSpacing.lg, - paddingBottom: BrandSpacing.xxl + BrandSpacing.xxl + BrandSpacing.xxl + BrandSpacing.md, - gap: BrandSpacing.lg, - }, - connectionList: { - borderRadius: BrandRadius.card, - overflow: "hidden", - }, - feedbackCard: { - borderWidth: 1, - borderRadius: BrandRadius.input, - paddingHorizontal: BrandSpacing.md, - paddingVertical: BrandSpacing.md, - }, - feedbackText: { - ...BrandType.body, - }, - actionStack: { - gap: BrandSpacing.sm + 2, - }, - footerAction: { - position: "absolute", - left: BrandSpacing.lg, - right: BrandSpacing.lg, - bottom: BrandSpacing.lg, - }, -}); diff --git a/src/app/(app)/(instructor-tabs)/instructor/profile/identity-verification.tsx b/src/app/(app)/(instructor-tabs)/instructor/profile/identity-verification.tsx index d7a9069..cbe94be 100644 --- a/src/app/(app)/(instructor-tabs)/instructor/profile/identity-verification.tsx +++ b/src/app/(app)/(instructor-tabs)/instructor/profile/identity-verification.tsx @@ -168,13 +168,7 @@ function LinkPill({ opacity: pressed ? 0.72 : 1, })} > - + {label} @@ -205,15 +199,8 @@ function LoaderDot({ delay, color }: { delay: number; color: string }) { return ( ); } @@ -268,20 +255,13 @@ function VerificationResolvingState({ label }: { label: string }) { return ( - + @@ -365,7 +343,7 @@ function VerificationResolvingState({ label }: { label: string }) { {t("profile.identityVerification.resolvingTitle")} @@ -422,8 +400,10 @@ export default function IdentityVerificationScreen() { const lastEventAtLabel = formatDateTime(diditVerification?.lastEventAt); const isInProgressState = status === "in_progress" || status === "pending" || status === "in_review"; - const diditStatusBackground = resolvedScheme === "dark" ? palette.accentDark : palette.accentLight; - const diditSectionBackground = resolvedScheme === "dark" ? palette.accentRowBgDark : palette.accentRowBgLight; + const diditStatusBackground = + resolvedScheme === "dark" ? palette.accentDark : palette.accentLight; + const diditSectionBackground = + resolvedScheme === "dark" ? palette.accentRowBgDark : palette.accentRowBgLight; const diditPressedBlue = palette.didit.accent; const openExternalUrl = useCallback( (url: string) => { @@ -457,7 +437,7 @@ export default function IdentityVerificationScreen() { { @@ -693,39 +673,29 @@ export default function IdentityVerificationScreen() { /> } > - + {showApprovalBurst ? : null} - + {t("profile.identityVerification.eyebrow")} - - + + {getStatusHeadline(status, t)} - + {getStatusBody(status, t)} @@ -734,11 +704,7 @@ export default function IdentityVerificationScreen() { {legalName ? ( - + {t("profile.identityVerification.verifiedLegalName")} @@ -747,7 +713,7 @@ export default function IdentityVerificationScreen() { ) : null} {verifiedAtLabel || lastEventAtLabel ? ( - + {verifiedAtLabel ? ( {t("profile.identityVerification.verifiedAt", { @@ -779,12 +745,14 @@ export default function IdentityVerificationScreen() { paddingVertical: BrandSpacing.md, paddingHorizontal: BrandSpacing.md, borderWidth: 1, - borderColor: busy || isRefreshing ? (palette.borderStrong as string) : palette.didit.accent, - backgroundColor: busy || isRefreshing - ? (palette.surfaceAlt as string) - : pressed - ? diditPressedBlue - : palette.didit.accent, + borderColor: + busy || isRefreshing ? (palette.borderStrong as string) : palette.didit.accent, + backgroundColor: + busy || isRefreshing + ? (palette.surfaceAlt as string) + : pressed + ? diditPressedBlue + : palette.didit.accent, opacity: busy || isRefreshing ? 0.7 : 1, })} > @@ -823,10 +791,7 @@ export default function IdentityVerificationScreen() { }} > {t("profile.identityVerification.whyTitle")} - + {t("profile.identityVerification.whyIntro")} diff --git a/src/app/(app)/(instructor-tabs)/instructor/profile/index.tsx b/src/app/(app)/(instructor-tabs)/instructor/profile/index.tsx index 805f820..e35fca6 100644 --- a/src/app/(app)/(instructor-tabs)/instructor/profile/index.tsx +++ b/src/app/(app)/(instructor-tabs)/instructor/profile/index.tsx @@ -559,7 +559,7 @@ export default function InstructorProfileScreen() { routeKey="instructor/profile" style={styles.screen} contentContainerStyle={{ - gap: 18, + gap: BrandSpacing.xl, }} topSpacing={18} bottomSpacing={32} diff --git a/src/app/(app)/(instructor-tabs)/instructor/profile/location.tsx b/src/app/(app)/(instructor-tabs)/instructor/profile/location.tsx index 296e73b..05c846d 100644 --- a/src/app/(app)/(instructor-tabs)/instructor/profile/location.tsx +++ b/src/app/(app)/(instructor-tabs)/instructor/profile/location.tsx @@ -2,7 +2,7 @@ import { useMutation, useQuery } from "convex/react"; import { useRouter } from "expo-router"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { StyleSheet, Text, View } from "react-native"; +import { Text, View } from "react-native"; import { LoadingScreen } from "@/components/loading-screen"; import { @@ -18,7 +18,7 @@ import { ActionButton } from "@/components/ui/action-button"; import { AddressAutocomplete } from "@/components/ui/address-autocomplete"; import { IconSymbol } from "@/components/ui/icon-symbol"; import { KitSwitch } from "@/components/ui/kit"; -import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; +import { BrandSpacing, BrandType } from "@/constants/brand"; import { useUser } from "@/contexts/user-context"; import { api } from "@/convex/_generated/api"; import { useAppInsets } from "@/hooks/use-app-insets"; @@ -267,27 +267,27 @@ export default function LocationScreen() { }; return ( - + - - + + - + - + - - + + - + - + - + ); } - -const styles = StyleSheet.create({ - screen: { - flex: 1, - position: "relative", - }, - heroCard: { - gap: BrandSpacing.lg, - borderWidth: 1, - borderRadius: BrandRadius.card, - borderCurve: "continuous", - padding: BrandSpacing.xl, - }, - heroHeaderRow: { - flexDirection: "row", - alignItems: "flex-start", - gap: BrandSpacing.md, - }, - heroCopy: { - flex: 1, - gap: BrandSpacing.sm, - minWidth: 0, - }, - heroIconWrap: { - width: BrandSpacing.iconContainer + BrandSpacing.xs + 2, - height: BrandSpacing.iconContainer + BrandSpacing.xs + 2, - borderRadius: BrandRadius.icon, - borderCurve: "continuous", - alignItems: "center", - justifyContent: "center", - }, - heroSignalsRow: { - flexDirection: "row", - gap: BrandSpacing.sm + 2, - }, - sectionBody: { - padding: BrandSpacing.lg, - gap: BrandSpacing.md, - }, - metaStrip: { - gap: BrandSpacing.xs, - borderWidth: 1, - borderRadius: BrandRadius.button, - borderCurve: "continuous", - paddingHorizontal: BrandSpacing.componentPadding, // 14px - paddingVertical: BrandSpacing.md, // 12px - }, - zoneStateCard: { - gap: BrandSpacing.sm, - borderWidth: 1, - borderRadius: BrandRadius.cardSubtle, // card - 6 - borderCurve: "continuous", - padding: BrandSpacing.lg, - }, - toggleRow: { - flexDirection: "row", - alignItems: "center", - gap: BrandSpacing.md, - borderWidth: 1, - borderRadius: BrandRadius.button, - borderCurve: "continuous", - paddingHorizontal: BrandSpacing.componentPadding, // 14px - paddingVertical: BrandSpacing.md, // 12px - }, - errorCard: { - borderWidth: 1, - borderRadius: BrandRadius.button, - borderCurve: "continuous", - paddingHorizontal: BrandSpacing.componentPadding, // 14px - paddingVertical: BrandSpacing.md, // 12px - }, - actionRail: { - position: "absolute", - left: BrandSpacing.lg, - right: BrandSpacing.lg, - gap: BrandSpacing.sm + 2, - }, -}); diff --git a/src/app/(app)/(instructor-tabs)/instructor/profile/payments.tsx b/src/app/(app)/(instructor-tabs)/instructor/profile/payments.tsx index d8da912..8f3ab24 100644 --- a/src/app/(app)/(instructor-tabs)/instructor/profile/payments.tsx +++ b/src/app/(app)/(instructor-tabs)/instructor/profile/payments.tsx @@ -4,11 +4,11 @@ import * as Haptics from "expo-haptics"; import type { Href } from "expo-router"; import { Redirect, useRouter } from "expo-router"; import * as WebBrowser from "expo-web-browser"; +import { vars } from "nativewind"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Alert, Platform, Pressable, View } from "react-native"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; -import { vars } from "nativewind"; import { LoadingScreen } from "@/components/loading-screen"; import { PaymentActivityList } from "@/components/payments/payment-activity-list"; import { @@ -18,7 +18,7 @@ import { import { ThemedText } from "@/components/themed-text"; import { IconSymbol } from "@/components/ui/icon-symbol"; import { KitSegmentedToggle, KitStatusBadge, KitSuccessBurst } from "@/components/ui/kit"; -import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; +import { BrandSpacing, BrandType } from "@/constants/brand"; import { useRapydReturn } from "@/contexts/rapyd-return-context"; import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; @@ -380,24 +380,15 @@ export default function ProfilePaymentsScreen() { routeKey="instructor/profile/payments" className="flex-1" style={vars({ "--tw-bg-app-bg": String(palette.appBg) })} - contentContainerClassName="flex-grow justify-center" - contentContainerStyle={{ - paddingHorizontal: BrandSpacing.lg, - }} + contentContainerClassName="flex-grow justify-center px-lg" bottomSpacing={BrandSpacing.lg} > {t("profile.payments.finalizingTitle")} {t("profile.payments.successTitle")} @@ -462,32 +445,22 @@ export default function ProfilePaymentsScreen() { routeKey="instructor/profile/payments" className="flex-1" style={vars({ "--tw-bg-app-bg": String(palette.appBg) })} - contentContainerClassName="flex-grow justify-center" - contentContainerStyle={{ - paddingHorizontal: BrandSpacing.lg, - }} + contentContainerClassName="flex-grow justify-center px-lg" bottomSpacing={BrandSpacing.lg} > @@ -502,8 +475,8 @@ export default function ProfilePaymentsScreen() { {t("profile.payments.verifyToConnectBankBody")} vars({ "--tw-bg-primary": String(palette.didit.accent), "--tw-text": String(palette.onPrimary), - borderRadius: BrandRadius.button, - paddingHorizontal: BrandSpacing.lg, - paddingVertical: BrandSpacing.md, opacity: pressed ? 0.85 : 1, }) } > - + {t("profile.payments.verifyToConnectBankCta")} @@ -540,10 +514,12 @@ export default function ProfilePaymentsScreen() { accessibilityRole="button" accessibilityLabel={t("common.cancel")} onPress={() => setShowVerifyModal(false)} - className="active:opacity-60" - style={{ paddingHorizontal: BrandSpacing.md, paddingVertical: BrandSpacing.sm + 2 }} + className="active:opacity-60 px-md py-sm" > - + {t("common.cancel")} @@ -558,25 +534,19 @@ export default function ProfilePaymentsScreen() { routeKey="instructor/profile/payments" className="flex-1" style={vars({ "--tw-bg-app-bg": String(palette.appBg) })} - contentContainerClassName="" - contentContainerStyle={{ gap: BrandSpacing.xl }} + contentContainerClassName="gap-xl" topSpacing={BrandSpacing.md} - bottomSpacing={40} + bottomSpacing={BrandSpacing.lg} > - + {/* Consolidated Error/Info Banner */} {destinationError || withdrawError || preferenceError ? ( {destinationError || withdrawError || preferenceError} @@ -584,10 +554,13 @@ export default function ProfilePaymentsScreen() { ) : destinationInfo || withdrawInfo || preferenceInfo ? ( - + {destinationInfo || withdrawInfo || preferenceInfo} @@ -618,19 +591,20 @@ export default function ProfilePaymentsScreen() { accessibilityRole="button" accessibilityLabel={t("profile.setup.verifyIdentity")} onPress={() => router.push(INSTRUCTOR_IDENTITY_VERIFICATION_ROUTE as Href)} - className="self-start rounded-full border active:bg-surface-alt" + className="self-start rounded-pill border active:bg-surface-alt px-md py-sm" style={({ pressed }) => vars({ "--tw-bg-primary-subtle": String(palette.primarySubtle), "--tw-border": String(palette.primary), "--tw-text-primary": String(palette.primary), - paddingHorizontal: BrandSpacing.md, - paddingVertical: BrandSpacing.sm, - backgroundColor: pressed ? palette.surfaceAlt : palette.primarySubtle, + backgroundColor: String(pressed ? palette.surfaceAlt : palette.primarySubtle), }) } > - + {t("profile.setup.verifyIdentity")} @@ -646,12 +620,12 @@ export default function ProfilePaymentsScreen() { {/* Hero Balance Card */} - + - + {formatAgorotCurrency( payoutSummary?.availableAmountAgorot ?? 0, @@ -683,38 +655,36 @@ export default function ProfilePaymentsScreen() { - + {payoutSummary?.currency ?? "ILS"} - + { const isDisabled = !isManualPayoutMode || !isIdentityVerified || !payoutSummary?.hasVerifiedDestination || (payoutSummary?.availableAmountAgorot ?? 0) <= 0; - return vars( - { - "--tw-bg-primary": String(palette.onPrimary), - "--tw-text-primary": String(palette.onPrimary), - minHeight: BrandSpacing.xxl + BrandSpacing.xl, - gap: BrandSpacing.sm, - borderRadius: BrandRadius.button, - paddingHorizontal: BrandSpacing.lg, - paddingVertical: BrandSpacing.md, - opacity: withdrawBusy ? 0.5 : isDisabled ? 0.1 : 0.25, - }, - ); + return vars({ + "--tw-bg-primary": String(palette.onPrimary), + "--tw-text-primary": String(palette.onPrimary), + minHeight: BrandSpacing.xxl + BrandSpacing.xl, + opacity: withdrawBusy ? 0.5 : isDisabled ? 0.1 : 0.25, + }); }} onPress={() => { confirmWithdrawToBank(); @@ -728,7 +698,10 @@ export default function ProfilePaymentsScreen() { } > - + {t("profile.payments.withdraw")} @@ -740,22 +713,16 @@ export default function ProfilePaymentsScreen() { ? t("profile.payments.manageBank") : t("profile.payments.connectBank") } - className="flex-1 flex-row items-center justify-center border active:scale-[0.985]" + className="flex-1 flex-row items-center justify-center border active:scale-[0.985] gap-sm rounded-button px-lg py-md" style={({ pressed }) => { const hasDestination = payoutSummary?.hasVerifiedDestination; - return vars( - { - "--tw-bg-primary": String(hasDestination ? palette.onPrimary : palette.text), - "--tw-border": String(hasDestination ? palette.onPrimary : palette.border), - "--tw-text-primary": String(palette.onPrimary), - minHeight: BrandSpacing.xxl + BrandSpacing.xl, - gap: BrandSpacing.sm, - borderRadius: BrandRadius.button, - paddingHorizontal: BrandSpacing.lg, - paddingVertical: BrandSpacing.md, - opacity: hasDestination ? (pressed ? 0.2 : 0.14) : pressed ? 0.88 : 1, - }, - ); + return vars({ + "--tw-bg-primary": String(hasDestination ? palette.onPrimary : palette.text), + "--tw-border": String(hasDestination ? palette.onPrimary : palette.border), + "--tw-text-primary": String(palette.onPrimary), + minHeight: BrandSpacing.xxl + BrandSpacing.xl, + opacity: hasDestination ? (pressed ? 0.2 : 0.14) : pressed ? 0.88 : 1, + }); }} onPress={() => { if (!isIdentityVerified) { @@ -767,7 +734,10 @@ export default function ProfilePaymentsScreen() { disabled={onboardingBusy} > - + {payoutSummary?.hasVerifiedDestination ? t("profile.payments.manageBank") : t("profile.payments.connectBank")} @@ -777,13 +747,17 @@ export default function ProfilePaymentsScreen() { {/* Stats Row - Merged into Hero Card */} - - + + - + {t("profile.payments.pending")} - + - + {t("profile.payments.paid")} - + {t("profile.payments.preferenceTitle")} @@ -854,24 +832,24 @@ export default function ProfilePaymentsScreen() { {effectivePreferenceMode === "scheduled_date" ? ( - + setShowSchedulePicker((value) => !value)} - className="border active:bg-surface" + className="border active:bg-surface rounded-button-subtle px-lg py-md" style={({ pressed }) => vars({ "--tw-border": String(palette.border), "--tw-bg-app-bg": String(palette.appBg), - borderRadius: BrandRadius.buttonSubtle, - paddingHorizontal: BrandSpacing.lg, - paddingVertical: BrandSpacing.md, - backgroundColor: pressed ? palette.surface : palette.appBg, + backgroundColor: String(pressed ? palette.surface : palette.appBg), }) } > - + {t("profile.payments.preferenceScheduleAt")} {scheduledAtLabel} @@ -879,17 +857,11 @@ export default function ProfilePaymentsScreen() { {showSchedulePicker ? ( setShowSchedulePicker(false)} - className="self-start rounded-full active:bg-surface" + className="self-start rounded-full active:bg-surface px-md py-sm" style={({ pressed }) => vars({ "--tw-bg-primary-subtle": String(palette.primarySubtle), - paddingHorizontal: BrandSpacing.md, - paddingVertical: BrandSpacing.xs + 2, - backgroundColor: pressed ? palette.surface : palette.primarySubtle, + backgroundColor: String(pressed ? palette.surface : palette.primarySubtle), }) } > - + {t("common.done")} @@ -929,7 +902,7 @@ export default function ProfilePaymentsScreen() { ) : null} - + vars({ "--tw-border": String(palette.border), "--tw-bg-app-bg": String(palette.appBg), minHeight: BrandSpacing.xxl + BrandSpacing.md, - borderRadius: BrandRadius.buttonSubtle, - backgroundColor: pressed ? palette.surface : palette.appBg, + backgroundColor: String(pressed ? palette.surface : palette.appBg), }) } > @@ -960,17 +932,19 @@ export default function ProfilePaymentsScreen() { void savePayoutPreference("scheduled_date", scheduleDraft.getTime()); }} disabled={preferenceBusy} - className="flex-1 items-center justify-center active:scale-[0.985]" - style={({ pressed }) => + className="flex-1 items-center justify-center active:scale-[0.985] rounded-button-subtle" + style={() => vars({ "--tw-bg-payments-accent": String(palette.payments.accent), minHeight: BrandSpacing.xxl + BrandSpacing.md, - borderRadius: BrandRadius.buttonSubtle, opacity: preferenceBusy ? 0.6 : 1, }) } > - + {preferenceBusy ? t("profile.payments.preferenceSaving") : t("profile.payments.preferenceSaveSchedule")} @@ -992,25 +966,29 @@ export default function ProfilePaymentsScreen() { {selectedPaymentId ? ( - + {t("profile.payments.receipt")} setSelectedPaymentId(null)} - className="rounded-full active:opacity-84" - style={vars({ "--tw-bg-surface-alt": String(palette.surfaceAlt), paddingHorizontal: BrandSpacing.md, paddingVertical: BrandSpacing.xs + 2 })} + className="rounded-pill active:opacity-[0.84] px-md py-sm" + style={vars({ "--tw-bg-surface-alt": String(palette.surfaceAlt) })} > - + {t("profile.payments.close")} {isDetailLoading ? ( {t("profile.payments.loadingReceipt")} @@ -1018,8 +996,8 @@ export default function ProfilePaymentsScreen() { ) : !selectedPaymentDetail ? ( {t("profile.payments.paymentNotFound")} @@ -1027,34 +1005,28 @@ export default function ProfilePaymentsScreen() { ) : ( - + {formatAgorotCurrency( role === "studio" @@ -1064,13 +1036,19 @@ export default function ProfilePaymentsScreen() { selectedPaymentDetail.payment.currency, )} - + {formatDateTime(selectedPaymentDetail.payment.createdAt, locale)} - + - + {t("profile.payments.status")} @@ -1078,7 +1056,10 @@ export default function ProfilePaymentsScreen() { - + {t("profile.payments.payout")} @@ -1096,10 +1077,12 @@ export default function ProfilePaymentsScreen() { selectedPaymentDetail.invoice!.externalInvoiceUrl!, ); }} - className="flex-row items-center justify-between active:opacity-84" - style={{ paddingVertical: BrandSpacing.sm }} + className="flex-row items-center justify-between py-sm active:opacity-[0.84]" > - + {t("profile.payments.downloadInvoice")} @@ -1111,7 +1094,7 @@ export default function ProfilePaymentsScreen() { ) : null} - + + - - + + - + - + ); } - -const styles = StyleSheet.create({ - screen: { - flex: 1, - position: "relative", - }, - heroCard: { - gap: BrandSpacing.lg, - borderWidth: 1, - borderRadius: BrandRadius.card, - borderCurve: "continuous", - padding: BrandSpacing.xl, - }, - heroHeaderRow: { - flexDirection: "row", - alignItems: "flex-start", - gap: BrandSpacing.md, - }, - heroCopy: { - flex: 1, - gap: BrandSpacing.sm, - minWidth: 0, - }, - heroIconWrap: { - width: BrandSpacing.iconContainer + 8, // 46px - height: BrandSpacing.iconContainer + 8, // 46px - borderRadius: BrandRadius.icon, - borderCurve: "continuous", - alignItems: "center", - justifyContent: "center", - }, - heroSignalsRow: { - flexDirection: "row", - gap: BrandSpacing.sm + 2, // 10px - }, - errorCard: { - borderWidth: 1, - borderRadius: BrandRadius.button, - borderCurve: "continuous", - paddingHorizontal: BrandSpacing.componentPadding, // 14px - paddingVertical: BrandSpacing.md, // 12px - }, - actionRail: { - position: "absolute", - left: BrandSpacing.lg, - right: BrandSpacing.lg, - gap: BrandSpacing.sm + 2, // 10px - }, -}); diff --git a/src/app/(app)/(studio-tabs)/studio/profile/calendar-settings.tsx b/src/app/(app)/(studio-tabs)/studio/profile/calendar-settings.tsx index ca7963f..eda281f 100644 --- a/src/app/(app)/(studio-tabs)/studio/profile/calendar-settings.tsx +++ b/src/app/(app)/(studio-tabs)/studio/profile/calendar-settings.tsx @@ -2,22 +2,21 @@ import { useAction, useMutation, useQuery } from "convex/react"; import * as AuthSession from "expo-auth-session"; import { useRouter } from "expo-router"; import * as WebBrowser from "expo-web-browser"; +import { vars } from "nativewind"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Alert, Platform, Text, View } from "react-native"; - import appleCalendarIcon from "@/assets/images/calendar-apple-app-icon.jpg"; import googleCalendarIcon from "@/assets/images/calendar-google-app-icon.jpg"; -import { CalendarConnectionRow } from "@/components/profile/calendar-connection-row"; import { LoadingScreen } from "@/components/loading-screen"; +import { CalendarConnectionRow } from "@/components/profile/calendar-connection-row"; import { ProfileSubpageScrollView, useProfileSubpageSheet, } from "@/components/profile/profile-subpage-sheet"; import { ActionButton } from "@/components/ui/action-button"; import { KitList, KitSwitchRow } from "@/components/ui/kit"; -import { BrandRadius, BrandSpacing } from "@/constants/brand"; -import { vars } from "nativewind"; +import { BrandSpacing } from "@/constants/brand"; import { useUser } from "@/contexts/user-context"; import { api } from "@/convex/_generated/api"; import { useBrand } from "@/hooks/use-brand"; @@ -53,6 +52,7 @@ type StudioSettings = { type GoogleCalendarStatus = { connected: boolean; + hasRefreshToken: boolean; accountEmail?: string | undefined; lastError?: string | undefined; }; @@ -149,7 +149,10 @@ export default function StudioCalendarSettingsScreen() { } const hasGoogleConnection = Boolean(googleStatus?.connected); - const isGoogleConnected = provider === "google" && hasGoogleConnection; + const hasGoogleRefreshToken = Boolean(googleStatus?.hasRefreshToken); + const needsGoogleReconnect = hasGoogleConnection && !hasGoogleRefreshToken; + const canUseGoogleCalendar = hasGoogleConnection && hasGoogleRefreshToken; + const isGoogleConnected = provider === "google" && canUseGoogleCalendar; const isAppleConnected = provider === "apple"; const isBusy = isSaving || isConnectingGoogle || isDisconnectingGoogle || isSyncingGoogle; @@ -320,6 +323,13 @@ export default function StudioCalendarSettingsScreen() { }; const onSyncGoogleNow = async () => { + if (!canUseGoogleCalendar) { + Alert.alert( + t("profile.settings.errors.saveFailed"), + t("profile.settings.calendar.googleReconnectRequired"), + ); + return; + } setIsSyncingGoogle(true); try { await syncGoogleCalendar({}); @@ -428,7 +438,11 @@ export default function StudioCalendarSettingsScreen() { routeKey="studio/profile/calendar-settings" className="flex-1" style={{ backgroundColor: palette.appBg }} - contentContainerStyle={{ paddingHorizontal: BrandSpacing.lg, paddingBottom: 128, gap: BrandSpacing.md }} + contentContainerStyle={{ + paddingHorizontal: BrandSpacing.lg, + paddingBottom: 128, + gap: BrandSpacing.md, + }} > - + {googleStatus.lastError} ) : null} + {needsGoogleReconnect ? ( + + + {t("profile.settings.calendar.googleReconnectRequired")} + + + ) : null} + {googleConfigError ? ( ) : null} - {isGoogleConnected ? ( + {provider === "google" ? ( { void onSyncGoogleNow(); }} - disabled={isSyncingGoogle || isBusy} + disabled={!canUseGoogleCalendar || isSyncingGoogle || isBusy} palette={palette} fullWidth /> diff --git a/src/app/(app)/(studio-tabs)/studio/profile/index.tsx b/src/app/(app)/(studio-tabs)/studio/profile/index.tsx index 6d31836..2f4e7cd 100644 --- a/src/app/(app)/(studio-tabs)/studio/profile/index.tsx +++ b/src/app/(app)/(studio-tabs)/studio/profile/index.tsx @@ -6,27 +6,26 @@ import type { TFunction } from "i18next"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { StyleSheet, Text, useWindowDimensions, View } from "react-native"; - -import { ThemedText } from "@/components/themed-text"; -import { IconSymbol } from "@/components/ui/icon-symbol"; - import { TabScreenRoot } from "@/components/layout/tab-screen-root"; import { useGlobalTopSheet } from "@/components/layout/top-sheet-registry"; import { useDeferredTabMount } from "@/components/layout/use-deferred-tab-mount"; +import { ProfileRoleSwitcherCard } from "@/components/profile/profile-role-switcher-card"; import { ProfileSectionCard, ProfileSectionHeader, ProfileSettingRow, } from "@/components/profile/profile-settings-sections"; -import { ProfileRoleSwitcherCard } from "@/components/profile/profile-role-switcher-card"; import { ProfileIndexScrollView } from "@/components/profile/profile-subpage-sheet"; import { getProfileHeaderExpandedHeight, ProfileDesktopHeroPanel, ProfileHeaderSheet, } from "@/components/profile/profile-tab"; +import { ThemedText } from "@/components/themed-text"; import { ChoicePill } from "@/components/ui/choice-pill"; +import { IconSymbol } from "@/components/ui/icon-symbol"; import { KitSwitch } from "@/components/ui/kit"; +import { BrandRadius, BrandSpacing } from "@/constants/brand"; import { useUser } from "@/contexts/user-context"; import { api } from "@/convex/_generated/api"; import { isSportType, toSportLabel } from "@/convex/constants"; @@ -35,7 +34,6 @@ import { useAppLanguage } from "@/hooks/use-app-language"; import { useBrand } from "@/hooks/use-brand"; import { useLayoutBreakpoint } from "@/hooks/use-layout-breakpoint"; import { useThemePreference } from "@/hooks/use-theme-preference"; -import { BrandSpacing } from "@/constants/brand"; import { EXPIRY_OVERRIDE_PRESETS } from "@/lib/jobs-utils"; import { omitUndefined } from "@/lib/omit-undefined"; import { buildRoleTabRoute, ROLE_TAB_ROUTE_NAMES } from "@/navigation/role-routes"; @@ -102,8 +100,9 @@ export default function StudioProfileScreen() { const switchActiveRole = useMutation(api.users.switchActiveRole); const [autoAcceptDefault, setAutoAcceptDefault] = useState(false); const [isSavingAutoAcceptDefault, setIsSavingAutoAcceptDefault] = useState(false); - const [autoExpireMinutesBefore, setAutoExpireMinutesBefore] = useState(undefined); - const [isSavingAutoExpireMinutes, setIsSavingAutoExpireMinutes] = useState(false); + const [autoExpireMinutesBefore, setAutoExpireMinutesBefore] = useState( + undefined, + ); useEffect(() => { if (studioSettings) { @@ -155,7 +154,6 @@ export default function StudioProfileScreen() { } const previousValue = autoExpireMinutesBefore; setAutoExpireMinutesBefore(minutes); - setIsSavingAutoExpireMinutes(true); void updateMyStudioSettings({ studioName: studioSettings.studioName ?? "", address: studioSettings.address ?? "", @@ -168,13 +166,9 @@ export default function StudioProfileScreen() { latitude: studioSettings.latitude, longitude: studioSettings.longitude, }), - }) - .catch(() => { - setAutoExpireMinutesBefore(previousValue); - }) - .finally(() => { - setIsSavingAutoExpireMinutes(false); - }); + }).catch(() => { + setAutoExpireMinutesBefore(previousValue); + }); }, [autoExpireMinutesBefore, studioSettings, updateMyStudioSettings], ); @@ -527,16 +521,16 @@ export default function StudioProfileScreen() { style={{ flexDirection: "row", alignItems: "flex-start", - gap: 14, - paddingHorizontal: 18, - paddingVertical: 15, + gap: BrandSpacing.componentPadding, + paddingHorizontal: BrandSpacing.lg, + paddingVertical: BrandSpacing.componentPadding, }} > - + {t("profile.settings.autoExpire.description")} - + - + {t("profile.settings.autoExpire.description")} - + - + {t("profile.payments.summarySubtitle")} - + {selectedPaymentId ? ( - - + + {t("profile.payments.detailTitle")} ) : ( - + ) : null} - + diff --git a/src/app/(auth)/sign-in-screen.tsx b/src/app/(auth)/sign-in-screen.tsx index 03d6a55..d7e4356 100644 --- a/src/app/(auth)/sign-in-screen.tsx +++ b/src/app/(auth)/sign-in-screen.tsx @@ -45,10 +45,7 @@ function MessageBanner({ const textColor = tone === "danger" ? (palette.danger as string) : (palette.textMuted as string); return ( - + - + - - + + {t("auth.or")} - + - + } @@ -341,7 +344,7 @@ export default function SignInScreen() { ) : ( - + - + - + {infoMessage ? ( ) : null} diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 5425b86..19376d4 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -1,5 +1,4 @@ import "@/global.css"; -import { BrandSpacing } from "@/constants/brand"; import { ConvexAuthProvider } from "@convex-dev/auth/react"; import MaterialIcons from "@expo/vector-icons/MaterialIcons"; import { BarlowCondensed_800ExtraBold } from "@expo-google-fonts/barlow-condensed"; @@ -24,9 +23,9 @@ import { LogBox, Platform, View } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { configureReanimatedLogger, ReanimatedLogLevel } from "react-native-reanimated"; import { SafeAreaProvider } from "react-native-safe-area-context"; - import { AppSafeRoot } from "@/components/layout/app-safe-root"; import { ThemedText } from "@/components/themed-text"; +import { BrandSpacing } from "@/constants/brand"; import { RapydReturnProvider } from "@/contexts/rapyd-return-context"; import { SystemUiProvider, useSystemUi } from "@/contexts/system-ui-context"; import { UserProvider } from "@/contexts/user-context"; @@ -132,7 +131,10 @@ function RootLayoutContent() { if (!isConvexUrlConfigured || !convex) { return ( - + {i18n.t("errors.configuration.title")} {i18n.t("errors.configuration.body")} @@ -152,7 +154,7 @@ function RootLayoutContent() { const statusInsetColor = topInsetBackgroundColor ?? fallbackBackgroundColor; return ( - + diff --git a/src/components/calendar/calendar-controller-helpers.ts b/src/components/calendar/calendar-controller-helpers.ts index 8fdc5ce..d86fe61 100644 --- a/src/components/calendar/calendar-controller-helpers.ts +++ b/src/components/calendar/calendar-controller-helpers.ts @@ -11,6 +11,7 @@ export type CalendarVisibilityFilters = Record { - if (Platform.OS === "android") { - UIManager.setLayoutAnimationEnabledExperimental?.(true); - } - }, []); - const handleExternalCalendarToggle = useCallback(() => { listRef.current?.prepareForLayoutAnimationRender?.(); LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); diff --git a/src/components/calendar/use-calendar-tab-controller.ts b/src/components/calendar/use-calendar-tab-controller.ts index e9a3b3b..00e12bc 100644 --- a/src/components/calendar/use-calendar-tab-controller.ts +++ b/src/components/calendar/use-calendar-tab-controller.ts @@ -95,7 +95,10 @@ export function useCalendarTabController() { ); const calendarSettings = role === "instructor" ? instructorSettings : studioSettings; - const canShowGoogleAgenda = Boolean(role && googleStatus?.connected === true); + const canUseGoogleCalendar = Boolean( + role && googleStatus?.connected === true && googleStatus?.hasRefreshToken === true, + ); + const canShowGoogleAgenda = canUseGoogleCalendar; const shouldFetchGoogleAgenda = canShowGoogleAgenda && (visibilityFilters.timedCalendarEvents || visibilityFilters.allDayCalendarEvents); @@ -288,7 +291,7 @@ export function useCalendarTabController() { if (!role) { return; } - if (!googleStatus?.connected) { + if (!canUseGoogleCalendar) { return; } if (!calendarSettings || calendarSettings.calendarProvider !== "google") { @@ -310,7 +313,7 @@ export function useCalendarTabController() { }, [ calendarSettings, endTime, - googleStatus?.connected, + canUseGoogleCalendar, role, startTime, shouldFetchGoogleAgenda, diff --git a/src/components/home/home-agenda-widget.tsx b/src/components/home/home-agenda-widget.tsx index b592b78..4699ae7 100644 --- a/src/components/home/home-agenda-widget.tsx +++ b/src/components/home/home-agenda-widget.tsx @@ -1,6 +1,6 @@ import type { TFunction } from "i18next"; import { useMemo } from "react"; -import { ScrollView, Text, View } from "react-native"; +import { ScrollView, StyleSheet, Text, View } from "react-native"; import Animated, { FadeInUp } from "react-native-reanimated"; import { HomeSectionHeading, HomeSurface } from "@/components/home/home-dashboard-layout"; import { getRelativeTimeLabel } from "@/components/home/home-shared"; @@ -8,7 +8,7 @@ import type { BrandPalette } from "@/constants/brand"; import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; import { toSportLabel } from "@/convex/constants"; -const TIME_WIDTH = BrandSpacing.iconContainerLarge; // 78px - equal to icon container large for alignment +const TIME_WIDTH = BrandSpacing.avatarLg; type AgendaItem = { id: string; @@ -90,8 +90,8 @@ export function HomeAgendaWidget({ @@ -112,8 +112,7 @@ export function HomeAgendaWidget({ @@ -122,7 +121,6 @@ export function HomeAgendaWidget({ nestedScrollEnabled showsVerticalScrollIndicator={false} style={maxHeight ? { maxHeight } : undefined} - contentContainerStyle={{ gap: 0 }} > {visibleItems.map((item, index) => { const isToday = item.startTime <= todayEnd; @@ -140,10 +138,11 @@ export function HomeAgendaWidget({ style={{ flexDirection: "row", alignItems: "center", - paddingVertical: BrandSpacing.sm + 2, - gap: BrandSpacing.md, - borderBottomWidth: index < visibleItems.length - 1 ? 1 : 0, - borderBottomColor: (palette.border as string) ?? "rgba(0,0,0,0.06)", + paddingVertical: BrandSpacing.controlY, + gap: BrandSpacing.stack, + borderBottomWidth: + index < visibleItems.length - 1 ? StyleSheet.hairlineWidth : 0, + borderBottomColor: palette.border as string, }} > - + diff --git a/src/components/home/home-dashboard-layout.tsx b/src/components/home/home-dashboard-layout.tsx index 4f0cf66..78973d8 100644 --- a/src/components/home/home-dashboard-layout.tsx +++ b/src/components/home/home-dashboard-layout.tsx @@ -11,8 +11,8 @@ export function useHomeDashboardLayout() { return { isWideWeb, isExpandedWeb, - sectionGap: isWideWeb ? BrandSpacing.xl + 4 : BrandSpacing.xl, - topRowGap: isWideWeb ? 20 : BrandSpacing.xl, + sectionGap: BrandSpacing.xl, + topRowGap: BrandSpacing.xl, chartFlex: isWideWeb ? 1.18 : 1, heroFlex: isWideWeb ? 0.82 : 1, actionColumnWidth: isWideWeb ? 170 : undefined, @@ -60,21 +60,18 @@ export function HomeSectionHeading({ eyebrow?: string; }) { return ( - + {eyebrow ? ( {eyebrow} ) : null} - - {title} - + {title} ); } diff --git a/src/components/home/home-header-sheet.tsx b/src/components/home/home-header-sheet.tsx index 30ee527..56dbf28 100644 --- a/src/components/home/home-header-sheet.tsx +++ b/src/components/home/home-header-sheet.tsx @@ -4,12 +4,12 @@ import { Pressable, Text, View } from "react-native"; import { KitFloatingBadge } from "@/components/ui/kit/kit-floating-badge"; import { ProfileAvatar } from "@/components/ui/profile-avatar"; import type { BrandPalette } from "@/constants/brand"; -import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; +import { BrandSpacing, BrandType } from "@/constants/brand"; -const SHEET_EXPANDED_CONTENT_HEIGHT = BrandSpacing.iconContainerLarge + BrandSpacing.lg; // 78 + 16 = 94 for expanded content area +const SHEET_EXPANDED_CONTENT_HEIGHT = BrandSpacing.avatarLg + BrandSpacing.lg; const SHEET_CONTENT_GAP = BrandSpacing.sm; -const AVATAR_SIZE = BrandSpacing.iconContainerLarge; // 78px - large avatar for header -const BADGE_SIZE = BrandSpacing.lg; // 24px - appropriate badge size +const AVATAR_SIZE = BrandSpacing.avatarLg; +const BADGE_SIZE = BrandSpacing.lg; export function getHomeHeaderExpandedHeight(safeTop: number) { return safeTop + SHEET_EXPANDED_CONTENT_HEIGHT; @@ -18,7 +18,7 @@ export function getHomeHeaderExpandedHeight(safeTop: number) { export function getHomeHeaderScrollTopPadding(_safeTop: number) { // GlobalTopSheet owns the safe top inset now, so page content only needs // header content height plus a small gap, not the system inset again. - return SHEET_EXPANDED_CONTENT_HEIGHT + SHEET_CONTENT_GAP + BrandSpacing.xl; + return SHEET_EXPANDED_CONTENT_HEIGHT + SHEET_CONTENT_GAP + BrandSpacing.insetRoomy; } type HomeHeaderSheetProps = { @@ -43,13 +43,9 @@ export const HomeHeaderSheet = memo(function HomeHeaderSheet({ return ( @@ -74,7 +70,7 @@ export const HomeHeaderSheet = memo(function HomeHeaderSheet({ ...BrandType.body, color: palette.onPrimary as string, opacity: 0.7, - marginTop: 2, + marginTop: BrandSpacing.xs, }} > {subtitle} @@ -87,7 +83,7 @@ export const HomeHeaderSheet = memo(function HomeHeaderSheet({ accessibilityLabel={onPressAvatar ? t("home.actions.profileTitle") : undefined} onPress={onPressAvatar} disabled={!onPressAvatar} - style={{ borderRadius: BrandRadius.card }} + className="rounded-soft" > @@ -101,21 +99,16 @@ type DotStatusPillProps = { export function DotStatusPill({ backgroundColor, color, label }: DotStatusPillProps) { return ( @@ -143,7 +136,7 @@ type MetricCellProps = { /** Label + value metric pair used in job cards. */ export function MetricCell({ align = "flex-start", icon, label, value, palette }: MetricCellProps) { return ( - + {icon ? : null} diff --git a/src/components/home/instructor-home-content.tsx b/src/components/home/instructor-home-content.tsx index 26b9efe..32a9c9a 100644 --- a/src/components/home/instructor-home-content.tsx +++ b/src/components/home/instructor-home-content.tsx @@ -12,7 +12,7 @@ import { import { useScrollSheetBindings } from "@/components/layout/scroll-sheet-provider"; import { TabScreenScrollView } from "@/components/layout/tab-screen-scroll-view"; import type { BrandPalette } from "@/constants/brand"; -import { BrandSpacing } from "@/constants/brand"; +import { BrandRadius, BrandSpacing } from "@/constants/brand"; import type { Id } from "@/convex/_generated/dataModel"; import { useAppInsets } from "@/hooks/use-app-insets"; @@ -73,10 +73,10 @@ export function InstructorHomeContent({ style={{ flex: 1 }} topInsetTone="sheet" contentContainerStyle={{ - paddingHorizontal: BrandSpacing.xl, + paddingHorizontal: BrandSpacing.insetRoomy, paddingTop: getHomeHeaderScrollTopPadding(safeTop), - paddingBottom: BrandSpacing.xxl, - gap: layout.sectionGap, + paddingBottom: BrandSpacing.section, + gap: BrandSpacing.section, }} > {visibleAvailableJobs.map((job) => ( @@ -117,8 +117,8 @@ export function InstructorHomeContent({ ) : null} - - + + - + @@ -92,11 +92,11 @@ export function StudioHomeContent({ palette={palette} tone="primary" style={{ - padding: BrandSpacing.xl, - gap: BrandSpacing.lg, + padding: BrandSpacing.insetRoomy, + gap: BrandSpacing.stackRoomy, }} > - + - + @@ -197,14 +197,14 @@ export function StudioHomeContent({ {jobsNeedingReview.length > 0 ? ( - + {jobsNeedingReview.map((job, index) => ( - + - + 0 ? 220 : 180).duration(320)} style={{ flex: layout.isWideWeb && jobsNeedingReview.length > 0 ? 0.92 : undefined, - gap: 12, + gap: BrandSpacing.stack, }} > {recentJobs.length === 0 ? ( - + {t("home.studio.noRecent")} @@ -305,7 +308,7 @@ export function StudioHomeContent({ ) : ( - + {visibleRecentJobs.map((job, index) => ( - + - + ({ stickyHeader: ( - + @@ -173,15 +167,15 @@ export function InstructorFeed() { } size="sm" - railColor={RAIL_COLOR} - selectedColor={SELECTED_COLOR} - labelColor={LABEL_COLOR} - selectedLabelColor={String(palette.onPrimary)} - dividerColor={DIVIDER_COLOR} + railColor={String(palette.primarySubtle)} + selectedColor={String(palette.surface)} + labelColor={String(palette.text)} + selectedLabelColor={String(palette.primaryPressed)} + dividerColor={String(palette.border)} /> @@ -190,10 +184,6 @@ export function InstructorFeed() { tone="error" message={applyErrorMessage} onDismiss={() => setApplyErrorMessage(null)} - borderColor="transparent" - backgroundColor={palette.dangerSubtle} - textColor={palette.danger} - iconColor={palette.danger} /> ) : null} @@ -294,12 +284,12 @@ export function InstructorFeed() { minHeight: listViewportMinHeight, justifyContent: "center", alignItems: "center", - paddingHorizontal: BrandSpacing.xl, + paddingHorizontal: BrandSpacing.lg, }} > - + {t("jobsTab.instructorFeed.emptyInstructorShort")} diff --git a/src/components/jobs/instructor/instructor-job-card.tsx b/src/components/jobs/instructor/instructor-job-card.tsx index dd1546c..e06fcd3 100644 --- a/src/components/jobs/instructor/instructor-job-card.tsx +++ b/src/components/jobs/instructor/instructor-job-card.tsx @@ -6,9 +6,6 @@ import { IconSymbol } from "@/components/ui/icon-symbol"; import { KitSurface } from "@/components/ui/kit"; import type { BrandPalette } from "@/constants/brand"; import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; - -// Image panel takes 44% on mobile, responsive adjustment handled via layout breakpoint -const IMAGE_PANEL_WIDTH_PERCENT = "44%"; // Keep as percent for fluid layout import { getZoneLabel } from "@/constants/zones"; import type { Id } from "@/convex/_generated/dataModel"; import { toSportLabel } from "@/convex/constants"; @@ -22,6 +19,9 @@ import { type JobClosureReason, } from "@/lib/jobs-utils"; +// Image panel takes 44% on mobile, responsive adjustment handled via layout breakpoint. +const IMAGE_PANEL_WIDTH_PERCENT = "44%"; + export type InstructorMarketplaceJob = { jobId: Id<"jobs">; studioId: Id<"studioProfiles">; @@ -75,17 +75,17 @@ function StudioImagePanel({ }) { return ( {imageUrl ? ( @@ -135,15 +135,8 @@ function JobExpiryPill({ return ( {showStudioImage ? ( @@ -236,7 +229,7 @@ export function InstructorJobCard({ /> ) : null} - + {metaLine} - + {formatTime(job.startTime, locale)} @@ -276,7 +269,7 @@ export function InstructorJobCard({ {formatTime(job.endTime, locale)} - + {expiry ? ( - + {job.applicationStatus ? ( + {title} {subtitle ? ( diff --git a/src/components/jobs/notice-banner.tsx b/src/components/jobs/notice-banner.tsx index 444c66e..46cc2cc 100644 --- a/src/components/jobs/notice-banner.tsx +++ b/src/components/jobs/notice-banner.tsx @@ -2,15 +2,17 @@ import MaterialIcons from "@expo/vector-icons/MaterialIcons"; import { type ColorValue, Pressable, StyleSheet, View, type ViewStyle } from "react-native"; import { ThemedText } from "@/components/themed-text"; +import { BrandRadius, BrandSpacing } from "@/constants/brand"; +import { useBrand } from "@/hooks/use-brand"; type NoticeBannerProps = { tone: "success" | "error"; message: string; onDismiss: () => void; - borderColor: ColorValue; - backgroundColor: ColorValue; - textColor: ColorValue; - iconColor: ColorValue; + borderColor?: ColorValue; + backgroundColor?: ColorValue; + textColor?: ColorValue; + iconColor?: ColorValue; style?: ViewStyle; }; @@ -24,17 +26,42 @@ export function NoticeBanner({ iconColor, style, }: NoticeBannerProps) { + const palette = useBrand(); + const toneColors = + tone === "success" + ? { + borderColor: palette.success, + backgroundColor: palette.successSubtle, + textColor: palette.success, + iconColor: palette.success, + } + : { + borderColor: palette.danger, + backgroundColor: palette.dangerSubtle, + textColor: palette.danger, + iconColor: palette.danger, + }; + + const resolvedBorderColor = borderColor ?? toneColors.borderColor; + const resolvedBackgroundColor = backgroundColor ?? toneColors.backgroundColor; + const resolvedTextColor = textColor ?? toneColors.textColor; + const resolvedIconColor = iconColor ?? toneColors.iconColor; + return ( - + {message} [styles.dismiss, { opacity: pressed ? 0.7 : 1 }]} > - + ); @@ -52,13 +79,13 @@ export function NoticeBanner({ const styles = StyleSheet.create({ container: { borderWidth: 1, - borderRadius: 14, + borderRadius: BrandRadius.buttonSubtle, borderCurve: "continuous", - paddingHorizontal: 12, - paddingVertical: 10, + paddingHorizontal: BrandSpacing.md, + paddingVertical: BrandSpacing.sm, flexDirection: "row", alignItems: "flex-start", - gap: 10, + gap: BrandSpacing.sm, }, copy: { flex: 1, diff --git a/src/components/jobs/studio-feed.tsx b/src/components/jobs/studio-feed.tsx index eb6c77f..778b64c 100644 --- a/src/components/jobs/studio-feed.tsx +++ b/src/components/jobs/studio-feed.tsx @@ -184,10 +184,6 @@ export function StudioFeed() { tone="error" message={errorMessage} onDismiss={() => setErrorMessage(null)} - borderColor="transparent" - backgroundColor={palette.dangerSubtle} - textColor={palette.danger} - iconColor={palette.danger} /> ) : null} {statusMessage ? ( @@ -195,10 +191,6 @@ export function StudioFeed() { tone="success" message={statusMessage} onDismiss={() => setStatusMessage(null)} - borderColor="transparent" - backgroundColor={palette.successSubtle} - textColor={palette.text} - iconColor={palette.success as import("react-native").ColorValue} /> ) : null} {studioJobs === undefined ? ( @@ -341,10 +333,10 @@ const styles = StyleSheet.create({ gap: BrandSpacing.md, }, emptyStateWrap: { - paddingHorizontal: 16, - paddingVertical: 20, + paddingHorizontal: BrandSpacing.lg, + paddingVertical: BrandSpacing.inset, alignItems: "center", justifyContent: "center", - gap: 10, + gap: BrandSpacing.sm, }, }); diff --git a/src/components/jobs/studio/create-job-sheet-sections.tsx b/src/components/jobs/studio/create-job-sheet-sections.tsx index 93a97d6..7eca569 100644 --- a/src/components/jobs/studio/create-job-sheet-sections.tsx +++ b/src/components/jobs/studio/create-job-sheet-sections.tsx @@ -1,13 +1,13 @@ import DateTimePicker from "@react-native-community/datetimepicker"; import { useTranslation } from "react-i18next"; -import { I18nManager, Platform, Pressable, ScrollView, Text, View } from "react-native"; +import { I18nManager, Platform, Pressable, ScrollView, View } from "react-native"; import { ThemedText } from "@/components/themed-text"; import { ActionButton } from "@/components/ui/action-button"; import { AppSymbol } from "@/components/ui/app-symbol"; import { ChoicePill } from "@/components/ui/choice-pill"; import { KitSegmentedToggle } from "@/components/ui/kit"; import { KitTextField } from "@/components/ui/kit/kit-text-field"; -import type { BrandPalette } from "@/constants/brand"; +import { type BrandPalette, BrandRadius, BrandSpacing } from "@/constants/brand"; import { SPORT_TYPES, toSportLabel } from "@/convex/constants"; import type { StudioDraft } from "@/lib/jobs-utils"; import { @@ -50,10 +50,8 @@ export function SportPickerSection({ : t("jobsTab.form.pickSport"); return ( - - - {t("jobsTab.form.sport")} - + + {t("jobsTab.form.sport")} {sportPickerOpen ? ( - + { @@ -116,9 +114,9 @@ export function SportPickerSection({ contentContainerStyle={{ flexDirection: "row", flexWrap: "wrap", - gap: 10, - paddingTop: 4, - paddingBottom: 4, + gap: BrandSpacing.stackTight, + paddingTop: BrandSpacing.xs, + paddingBottom: BrandSpacing.xs, }} > {filteredSports.length > 0 ? ( @@ -141,7 +139,7 @@ export function SportPickerSection({ ) : ( {t("jobsTab.form.noSportResults")} @@ -174,10 +172,8 @@ export function ScheduleSection({ const { t } = useTranslation(); return ( - - - {t("jobsTab.form.schedule")} - + + {t("jobsTab.form.schedule")} - + @@ -244,7 +240,7 @@ export function PayParticipantsSection({ draft, setDraft }: PayParticipantsSecti const { t } = useTranslation(); return ( - + - - - {t("jobsTab.form.closeApplications")} - + + + {t("jobsTab.form.closeApplications")} {t("jobsTab.form.closeApplicationsDescription")} - + - - - {t("jobsTab.form.boostOnBoard")} - + + {t("jobsTab.form.boostOnBoard")} {t("jobsTab.form.boostOnBoardDescription")} @@ -374,7 +366,7 @@ export function NotesSection({ draft, setDraft }: NotesSectionProps) { multiline numberOfLines={4} placeholder={t("jobsTab.form.notesPlaceholder")} - style={{ minHeight: 100, textAlignVertical: "top" }} + style={{ minHeight: BrandSpacing.multilineInputMinHeight, textAlignVertical: "top" }} /> ); } @@ -389,7 +381,9 @@ type SubmitBarProps = { export function SubmitBar({ draft, isSubmitting, palette, onPost }: SubmitBarProps) { const { t } = useTranslation(); return ( - + ({ - minHeight: 56, + minHeight: BrandSpacing.controlLg + BrandSpacing.xs, width: "100%", - borderRadius: 18, + borderRadius: BrandRadius.medium, borderCurve: "continuous", backgroundColor: isSubmitting || !draft.sport @@ -408,21 +402,14 @@ export function SubmitBar({ draft, isSubmitting, palette, onPost }: SubmitBarPro flexDirection: "row", alignItems: "center", justifyContent: "center", - gap: 10, + gap: BrandSpacing.stackTight, opacity: pressed ? 0.92 : 1, })} > - + {isSubmitting ? t("jobsTab.actions.posting") : t("jobsTab.actions.post")} - + ); @@ -452,7 +439,13 @@ export function PickerDock({ const { t } = useTranslation(); if (!visible) return null; return ( - + - - {t("jobsTab.studioCreateTitle")} - - {t("jobsTab.studioCreateTitle")} + innerRef.current?.close()} - style={({ pressed }) => [ - styles.closeButton, - { - backgroundColor: palette.surfaceAlt as string, - opacity: pressed ? 0.72 : 1, - }, - ]} - > - - + size={BrandSpacing.controlSm} + tone="secondary" + backgroundColorOverride={String(palette.surfaceAlt)} + icon={} + /> @@ -264,16 +257,9 @@ const styles = StyleSheet.create({ flexDirection: "row", alignItems: "center", justifyContent: "space-between", - marginBottom: 24, - }, - closeButton: { - width: 36, - height: 36, - borderRadius: 18, - alignItems: "center", - justifyContent: "center", + marginBottom: BrandSpacing.xl, }, form: { - gap: 20, + gap: BrandSpacing.stackLoose, }, }); diff --git a/src/components/jobs/studio/studio-jobs-list-parts.tsx b/src/components/jobs/studio/studio-jobs-list-parts.tsx index efab556..529c80d 100644 --- a/src/components/jobs/studio/studio-jobs-list-parts.tsx +++ b/src/components/jobs/studio/studio-jobs-list-parts.tsx @@ -25,7 +25,7 @@ import { appStatusDot, paymentDotColor } from "./studio-jobs-list.helpers"; import type { StudioJob, StudioJobApplication } from "./studio-jobs-list.types"; const AVATAR_SIZE = BrandSpacing.xxl + BrandSpacing.xxl + 2; -const AVATAR_RADIUS = BrandRadius.card; +const AVATAR_RADIUS = BrandRadius.soft; const PAYMENT_STATUS_KEY: Record = { created: "jobsTab.checkout.paymentStatus.created", @@ -112,14 +112,9 @@ function MetaPill({ return ( @@ -148,8 +143,8 @@ function InlineMeta({ strong?: boolean; }) { return ( - - + + - + + onReview(application.applicationId, "rejected")} @@ -406,29 +386,16 @@ export const StudioJobCard = memo(function StudioJobCard({ padding={0} gap={0} style={{ - borderRadius: isWideWeb ? 28 : BrandRadius.card, + borderRadius: BrandRadius.soft, borderCurve: "continuous", backgroundColor: cardBackground, overflow: "hidden", }} > - - - - + + + + - + - + - + {boost.badgeKey ? ( - + {t("jobsTab.card.settlement")} - + 0 ? ( - - + + ) : acceptedApplication ? ( {t("jobsTab.card.assignedTo", { name: acceptedApplication.instructorName })} ) : job.applicationsCount > 0 ? ( {t("jobsTab.card.applicantsProcessed", { count: job.applicationsCount })} diff --git a/src/components/jobs/studio/studio-jobs-top-sheet.tsx b/src/components/jobs/studio/studio-jobs-top-sheet.tsx index 1cdd453..be7c026 100644 --- a/src/components/jobs/studio/studio-jobs-top-sheet.tsx +++ b/src/components/jobs/studio/studio-jobs-top-sheet.tsx @@ -40,7 +40,7 @@ export function StudioJobsTopSheetHeader({ return ( } /> @@ -69,15 +69,15 @@ export function StudioJobsTopSheetHeader({ } size="sm" - railColor="rgba(26, 16, 49, 0.72)" - selectedColor="rgba(255, 255, 255, 0.18)" - labelColor="rgba(255, 255, 255, 0.72)" - selectedLabelColor={String(palette.onPrimary)} - dividerColor="rgba(255, 255, 255, 0.12)" + railColor={String(palette.primarySubtle)} + selectedColor={String(palette.surface)} + labelColor={String(palette.text)} + selectedLabelColor={String(palette.primaryPressed)} + dividerColor={String(palette.border)} /> ); @@ -85,7 +85,7 @@ export function StudioJobsTopSheetHeader({ const styles = StyleSheet.create({ headerRow: { - minHeight: 44, + minHeight: BrandSpacing.controlMd, flexDirection: "row", alignItems: "center", justifyContent: "space-between", diff --git a/src/components/loading-screen.tsx b/src/components/loading-screen.tsx index b531c1d..e9db742 100644 --- a/src/components/loading-screen.tsx +++ b/src/components/loading-screen.tsx @@ -11,9 +11,9 @@ import { useBrand } from "@/hooks/use-brand"; const LAUNCH_ICON_SIZE = BrandSpacing.haloSize; // 180px - matches brand halo size for visual impact const LAUNCH_ICON_RADIUS = LAUNCH_ICON_SIZE / 2; // 90px const LAUNCH_INNER_SIZE = BrandSpacing.iconContainerLarge + BrandSpacing.xl; // 78 + 24 = 102px -const LAUNCH_INNER_RADIUS = BrandRadius.card; // 24px - matches card radius +const LAUNCH_INNER_RADIUS = BrandRadius.soft; // 24px - matches card radius const LAUNCH_SYMBOL_WRAPPER_SIZE = BrandSpacing.iconContainerLarge; // 78px -const LAUNCH_SYMBOL_WRAPPER_RADIUS = BrandRadius.cardSubtle; // 18px +const LAUNCH_SYMBOL_WRAPPER_RADIUS = BrandRadius.medium; // 18px type LoadingScreenProps = { variant?: "inline" | "launch"; @@ -49,25 +49,20 @@ export function LoadingScreen({ contentContainerStyle={{ flexGrow: 1, justifyContent: "center", - paddingHorizontal: 24, - paddingVertical: 40, + paddingHorizontal: BrandSpacing.xl, + paddingVertical: BrandSpacing.section, }} showsVerticalScrollIndicator={false} > - + @@ -78,7 +73,7 @@ export function LoadingScreen({ height: LAUNCH_INNER_SIZE, borderRadius: LAUNCH_INNER_RADIUS, borderCurve: "continuous", - backgroundColor: "rgba(255,255,255,0.14)", + backgroundColor: palette.surfaceAlt as string, alignItems: "center", justifyContent: "center", }} @@ -90,7 +85,7 @@ export function LoadingScreen({ height: LAUNCH_SYMBOL_WRAPPER_SIZE, borderRadius: LAUNCH_SYMBOL_WRAPPER_RADIUS, borderCurve: "continuous", - backgroundColor: "rgba(255,255,255,0.94)", + backgroundColor: palette.surface as string, alignItems: "center", justifyContent: "center", }} @@ -105,7 +100,7 @@ export function LoadingScreen({ + @@ -82,8 +81,8 @@ export function MapWebCommandPanel({ borderRadius: INNER_RADIUS, borderCurve: "continuous", backgroundColor: palette.surface as string, - paddingHorizontal: BrandSpacing.md + 2, - paddingVertical: BrandSpacing.md + 2, + paddingHorizontal: BrandSpacing.controlX, + paddingVertical: BrandSpacing.controlY, gap: BrandSpacing.sm, }} > @@ -94,9 +93,9 @@ export function MapWebCommandPanel({ borderRadius: METRIC_RADIUS, borderCurve: "continuous", backgroundColor: palette.surfaceAlt as string, - paddingHorizontal: BrandSpacing.md, + paddingHorizontal: BrandSpacing.controlX, paddingVertical: BrandSpacing.sm, - gap: 2, + gap: BrandSpacing.xs, }} > @@ -194,7 +193,7 @@ export function MapWebCommandPanel({ ) : null} - + {selectedZones.length === 0 ? ( - + @@ -285,8 +284,9 @@ export function MapWebCommandPanel({ ...BrandType.micro, color: focusZoneId === zone.id - ? "rgba(255,255,255,0.72)" + ? (palette.primary as string) : (palette.textMuted as string), + opacity: focusZoneId === zone.id ? 0.78 : 1, }} > {focusZoneId === zone.id @@ -305,18 +305,18 @@ export function MapWebCommandPanel({ style={({ pressed }) => ({ alignItems: "center", justifyContent: "center", - paddingHorizontal: BrandSpacing.md + 2, - paddingVertical: BrandSpacing.md + 2, + paddingHorizontal: BrandSpacing.controlX, + paddingVertical: BrandSpacing.controlY, backgroundColor: focusZoneId === zone.id - ? "rgba(255,255,255,0.14)" + ? (palette.primaryPressed as string) : (palette.surfaceAlt as string), opacity: pressed ? 0.88 : 1, })} > - + {filteredZones.map((zone) => { const selected = selectedZones.some((entry) => entry.id === zone.id); @@ -358,7 +358,7 @@ export function MapWebCommandPanel({ backgroundColor: selected ? (palette.primary as string) : (palette.surface as string), - paddingHorizontal: BrandSpacing.md + 2, + paddingHorizontal: BrandSpacing.controlX, paddingVertical: BrandSpacing.md, opacity: pressed ? 0.92 : 1, })} @@ -368,7 +368,7 @@ export function MapWebCommandPanel({ flexDirection: "row", alignItems: "center", justifyContent: "space-between", - gap: 12, + gap: BrandSpacing.md, }} > {selected ? t("mapTab.web.live") : t("mapTab.web.add")} diff --git a/src/components/map-tab/map-tab/map-web-header-panels.tsx b/src/components/map-tab/map-tab/map-web-header-panels.tsx index 1272fa1..ee5622c 100644 --- a/src/components/map-tab/map-tab/map-web-header-panels.tsx +++ b/src/components/map-tab/map-tab/map-web-header-panels.tsx @@ -5,8 +5,8 @@ import { ActionButton } from "@/components/ui/action-button"; import { type BrandPalette, BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; // Map web header panels - shares radii with command panel -const PANEL_RADIUS = BrandRadius.card + BrandSpacing.xs; // 24 + 4 = 28px -const INNER_RADIUS = BrandRadius.cardSubtle; // 18px +const PANEL_RADIUS = BrandRadius.soft; +const INNER_RADIUS = BrandRadius.medium; type MapWebHeaderPanelsProps = { t: TFunction; @@ -39,8 +39,8 @@ export function MapWebHeaderPanels({ borderRadius: PANEL_RADIUS, borderCurve: "continuous", backgroundColor: palette.surfaceAlt as string, - paddingHorizontal: BrandSpacing.lg + 2, - paddingVertical: BrandSpacing.lg + 2, + paddingHorizontal: BrandSpacing.lg, + paddingVertical: BrandSpacing.lg, gap: BrandSpacing.xs, }} > @@ -74,12 +74,12 @@ export function MapWebHeaderPanels({ @@ -119,17 +119,16 @@ export function MapWebHeaderPanels({ flex: 1, borderRadius: INNER_RADIUS, borderCurve: "continuous", - backgroundColor: "rgba(255,255,255,0.14)", - paddingHorizontal: BrandSpacing.md, + backgroundColor: palette.surfaceElevated as string, + paddingHorizontal: BrandSpacing.controlX, paddingVertical: BrandSpacing.sm, - gap: 2, + gap: BrandSpacing.xs, }} > @@ -151,17 +150,16 @@ export function MapWebHeaderPanels({ flex: 1.25, borderRadius: INNER_RADIUS, borderCurve: "continuous", - backgroundColor: "rgba(255,255,255,0.14)", - paddingHorizontal: BrandSpacing.md, + backgroundColor: palette.surfaceElevated as string, + paddingHorizontal: BrandSpacing.controlX, paddingVertical: BrandSpacing.sm, - gap: 2, + gap: BrandSpacing.xs, }} > {focusedZoneLabel diff --git a/src/components/map-tab/map-tab/map-web-workbench.tsx b/src/components/map-tab/map-tab/map-web-workbench.tsx index 71e2f94..89f5910 100644 --- a/src/components/map-tab/map-tab/map-web-workbench.tsx +++ b/src/components/map-tab/map-tab/map-web-workbench.tsx @@ -3,7 +3,7 @@ import { View } from "react-native"; import { QueueMap } from "@/components/maps/queue-map"; import type { QueueMapPin } from "@/components/maps/queue-map.types"; -import type { BrandPalette } from "@/constants/brand"; +import { type BrandPalette, BrandRadius, BrandSpacing } from "@/constants/brand"; import type { ZoneOption } from "@/constants/zones"; import { MapWebCommandPanel } from "./map-web-command-panel"; import { MapWebHeaderPanels } from "./map-web-header-panels"; @@ -54,7 +54,15 @@ export function MapWebWorkbench({ onSearchChange, }: MapWebWorkbenchProps) { return ( - + - + onPressZone(zone.id)} style={{ - minHeight: 34, + minHeight: COMPACT_ZONE_PILL_MIN_HEIGHT, paddingHorizontal: BrandSpacing.md, - paddingVertical: 4, + paddingVertical: BrandSpacing.xs, }} /> ); @@ -55,11 +58,11 @@ export function MapSelectedZonesStrip({ ) : ( onToggleCityExpanded(item.group.cityKey)} style={({ pressed }) => ({ - paddingHorizontal: BrandSpacing.md, + paddingHorizontal: BrandSpacing.controlX, paddingVertical: BrandSpacing.sm, opacity: pressed ? 0.82 : 1, })} diff --git a/src/components/maps/queue-map.native.tsx b/src/components/maps/queue-map.native.tsx index 0123c95..10baf48 100644 --- a/src/components/maps/queue-map.native.tsx +++ b/src/components/maps/queue-map.native.tsx @@ -11,13 +11,6 @@ import { BrandRadius, BrandSpacing, getMapBrandPalette } from "@/constants/brand import { getZoneIndexEntry, ISRAEL_MAP_INTERACTION_BOUNDS } from "@/constants/zones-map"; import { useBrand } from "@/hooks/use-brand"; import { useThemePreference } from "@/hooks/use-theme-preference"; - -// Map native controls - GPS and attribution buttons -const GPS_BUTTON_SIZE = BrandSpacing.iconContainer + BrandSpacing.lg; // 38 + 16 = 54px -const GPS_ICON_SIZE = BrandSpacing.md + BrandSpacing.xs; // 12 + 4 = 16px -const ATTRIBUTION_SIZE = BrandSpacing.iconContainer - BrandSpacing.xs; // 38 - 4 = 34px -const LOADING_ICON_SIZE = BrandSpacing.iconContainer + BrandSpacing.sm; // 38 + 8 = 46px -const LOADING_ICON_RADIUS = LOADING_ICON_SIZE / 2; // 23px import { ActionButton } from "../ui/action-button"; import { IconSymbol } from "../ui/icon-symbol"; import { KitSurface } from "../ui/kit"; @@ -35,6 +28,14 @@ import { } from "./queue-map.native.helpers"; import type { QueueMapProps } from "./queue-map.types"; +// Map native controls - GPS and attribution buttons +const GPS_BUTTON_SIZE = BrandSpacing.iconContainer + BrandSpacing.lg; +const GPS_ICON_SIZE = BrandSpacing.md + BrandSpacing.xs; +const ATTRIBUTION_SIZE = BrandSpacing.iconContainer - BrandSpacing.xs; +const ATTRIBUTION_ICON_SIZE = BrandSpacing.sm + BrandSpacing.xs; +const LOADING_ICON_SIZE = BrandSpacing.iconContainer + BrandSpacing.sm; +const LOADING_ICON_RADIUS = LOADING_ICON_SIZE / 2; + type MapLoadState = "loading" | "ready" | "error"; const MAP_LOADING_OVERLAY_DELAY_MS = 180; @@ -233,9 +234,9 @@ export const QueueMap = memo(function QueueMap({ styles.fallback, { backgroundColor: palette.surfaceAlt as string, - borderRadius: 28, + borderRadius: BrandRadius.soft, borderCurve: "continuous", - margin: 18, + margin: BrandSpacing.lg, }, ]} > @@ -332,7 +333,7 @@ export const QueueMap = memo(function QueueMap({ borderCurve: "continuous", alignItems: "center", justifyContent: "center", - backgroundColor: "rgba(255,255,255,0.86)", + backgroundColor: palette.surfaceElevated as string, }} > @@ -385,7 +386,7 @@ export const QueueMap = memo(function QueueMap({ height: GPS_BUTTON_SIZE, alignItems: "center", justifyContent: "center", - borderWidth: 1.2, + borderWidth: StyleSheet.hairlineWidth, borderRadius: BrandRadius.button, borderCurve: "continuous", backgroundColor: palette.surface as string, @@ -411,12 +412,12 @@ export const QueueMap = memo(function QueueMap({ { backgroundColor: palette.surfaceElevated as string, borderColor: palette.borderStrong as string, - borderWidth: 1, + borderWidth: StyleSheet.hairlineWidth, opacity: pressed ? 0.82 : 1, }, ]} > - + ) : null} @@ -437,30 +438,30 @@ const styles = StyleSheet.create({ bottom: BrandSpacing.lg, width: ATTRIBUTION_SIZE, height: ATTRIBUTION_SIZE, - borderRadius: BrandSpacing.iconContainer / 2, + borderRadius: BrandRadius.pill, alignItems: "center", justifyContent: "center", }, fallback: { alignItems: "center", justifyContent: "center", - gap: 10, - paddingHorizontal: 18, - paddingVertical: 16, + gap: BrandSpacing.sm, + paddingHorizontal: BrandSpacing.lg, + paddingVertical: BrandSpacing.lg, }, stateOverlay: { ...StyleSheet.absoluteFillObject, alignItems: "center", justifyContent: "center", - padding: 18, + padding: BrandSpacing.lg, }, stateCard: { width: "100%", - maxWidth: 360, + maxWidth: BrandSpacing.shellCommandPanel, alignItems: "center", - gap: 10, - paddingHorizontal: 18, - paddingVertical: 16, + gap: BrandSpacing.sm, + paddingHorizontal: BrandSpacing.lg, + paddingVertical: BrandSpacing.lg, borderWidth: StyleSheet.hairlineWidth, }, }); diff --git a/src/components/maps/queue-map.web.tsx b/src/components/maps/queue-map.web.tsx index 0cb3b8c..fe59c88 100644 --- a/src/components/maps/queue-map.web.tsx +++ b/src/components/maps/queue-map.web.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { Pressable, Text, View } from "react-native"; +import { Pressable, StyleSheet, Text, View } from "react-native"; import { AppSymbol } from "@/components/ui/app-symbol"; import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; @@ -9,9 +9,9 @@ import type { QueueMapProps } from "./queue-map.types"; import { buildCoverageNodes, getResponseLabel, getZone } from "./queue-map.web.helpers"; // Map web - desktop-focused map display with placeholder grid pattern -const MAP_RADIUS = BrandRadius.card + BrandSpacing.xs; // 24 + 4 = 28px -const INNER_RADIUS = BrandRadius.cardSubtle + BrandSpacing.xs; // 18 + 4 = 22px -const MAP_MIN_HEIGHT = BrandSpacing.mapMinHeight + BrandSpacing.lg; // 300 + 16 = 320px +const MAP_RADIUS = BrandRadius.soft; +const INNER_RADIUS = BrandRadius.medium; +const MAP_MIN_HEIGHT = BrandSpacing.mapCanvasMinHeight; export function QueueMap(props: QueueMapProps) { const { t, i18n } = useTranslation(); @@ -58,17 +58,17 @@ export function QueueMap(props: QueueMapProps) { style={{ flex: 1, justifyContent: "space-between", - padding: BrandSpacing.lg + 2, + padding: BrandSpacing.lg, backgroundColor: palette.surfaceAlt as string, }} > - + @@ -102,7 +102,11 @@ export function QueueMap(props: QueueMapProps) { backgroundColor: palette.primary as string, }} > - + @@ -124,7 +128,7 @@ export function QueueMap(props: QueueMapProps) { borderRadius: MAP_RADIUS, borderCurve: "continuous", backgroundColor: palette.appBg as string, - marginVertical: BrandSpacing.lg + 2, + marginVertical: BrandSpacing.lg, overflow: "hidden", }} > @@ -143,7 +147,7 @@ export function QueueMap(props: QueueMapProps) { left: 0, right: 0, top: `${14 + row * 18}%` as `${number}%`, - height: 1, + height: StyleSheet.hairlineWidth, backgroundColor: palette.surface as string, }} /> @@ -156,7 +160,7 @@ export function QueueMap(props: QueueMapProps) { top: 0, bottom: 0, left: `${8 + column * 15}%` as `${number}%`, - width: 1, + width: StyleSheet.hairlineWidth, backgroundColor: palette.surface as string, }} /> @@ -168,7 +172,7 @@ export function QueueMap(props: QueueMapProps) { top: "10%" as never, width: "52%" as never, height: "72%" as never, - borderRadius: 36, + borderRadius: BrandRadius.soft, backgroundColor: palette.primarySubtle as string, opacity: 0.9, transform: [{ rotate: "-10deg" }], @@ -181,7 +185,7 @@ export function QueueMap(props: QueueMapProps) { top: "22%" as never, width: "32%" as never, height: "48%" as never, - borderRadius: 28, + borderRadius: BrandRadius.medium, backgroundColor: palette.successSubtle as string, opacity: 0.82, transform: [{ rotate: "11deg" }], @@ -189,29 +193,29 @@ export function QueueMap(props: QueueMapProps) { /> + - - + {node.label} - + @@ -422,7 +426,7 @@ export function QueueMap(props: QueueMapProps) { ? (palette.primarySubtle as string) : (palette.surfaceAlt as string), paddingHorizontal: BrandSpacing.sm, - paddingVertical: BrandSpacing.xs + 1, + paddingVertical: BrandSpacing.xs, }} > ; @@ -61,12 +58,8 @@ function StatusDot({ tone, palette }: { tone: StatusTone; palette: BrandPalette }; return ( ); } @@ -83,16 +76,9 @@ export function PaymentActivityList({ }: PaymentActivityListProps) { const { t } = useTranslation(); return ( - - - + + + {title} {subtitle ? ( @@ -108,7 +94,7 @@ export function PaymentActivityList({ {items.length === 0 ? ( - + {emptyLabel} @@ -132,22 +118,16 @@ export function PaymentActivityList({ key={item.payment._id} {...listItemPressProps} accessibilityRole={onSelectPaymentId ? "button" : undefined} + className="flex-row items-center justify-between px-md py-md" style={({ pressed }) => ({ - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - paddingVertical: BrandSpacing.md, - paddingHorizontal: BrandSpacing.md, - backgroundColor: pressed && onSelectPaymentId ? palette.surfaceAlt : "transparent", + backgroundColor: + pressed && onSelectPaymentId ? palette.surfaceAlt : "transparent", borderBottomWidth: index < items.length - 1 ? 1 : 0, borderBottomColor: palette.border, })} > - - + + {sportLabel} @@ -159,7 +139,7 @@ export function PaymentActivityList({ - + )} @@ -75,18 +75,18 @@ const styles = StyleSheet.create({ rowInner: { flexDirection: "row", alignItems: "center", - gap: BrandSpacing.md, + gap: BrandSpacing.sm, paddingHorizontal: BrandSpacing.lg, paddingVertical: BrandSpacing.md, }, icon: { - width: 52, - height: 52, + width: BrandSpacing.controlLg, + height: BrandSpacing.controlLg, borderRadius: BrandRadius.input, }, copy: { flex: 1, - gap: 2, + gap: BrandSpacing.xs, }, title: { ...BrandType.bodyStrong, @@ -99,7 +99,7 @@ const styles = StyleSheet.create({ lineHeight: 20, }, trailing: { - width: 24, + width: BrandSpacing.xl, alignItems: "flex-end", justifyContent: "center", }, diff --git a/src/components/profile/profile-editor/profile-editor-actions.tsx b/src/components/profile/profile-editor/profile-editor-actions.tsx index 23d2e5a..d639907 100644 --- a/src/components/profile/profile-editor/profile-editor-actions.tsx +++ b/src/components/profile/profile-editor/profile-editor-actions.tsx @@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next"; import { View } from "react-native"; import { ActionButton } from "@/components/ui/action-button"; import type { BrandPalette } from "@/constants/brand"; +import { BrandSpacing } from "@/constants/brand"; type ProfileEditorActionsProps = { palette: BrandPalette; @@ -19,7 +20,7 @@ export function ProfileEditorActions({ const { t } = useTranslation(); return ( - + - + - + 0); return ( - + - + setShowSocialFields((value) => !value)} style={({ pressed }) => ({ opacity: pressed ? 0.68 : 1, - paddingHorizontal: 6, - paddingVertical: 4, + paddingHorizontal: BrandSpacing.sm, + paddingVertical: BrandSpacing.xs, })} > {showSocialFields ? ( - + {PROFILE_SOCIAL_FIELDS.map((field) => ( ["name"]; +const PROFILE_SECTION_HEADER_ICON_SIZE = 14; + +const PROFILE_SECTION_CARD_MARGIN_HORIZONTAL = BrandSpacing.inset; + +const PROFILE_SETTING_ROW_GAP = 14; +const PROFILE_SETTING_ROW_PADDING_HORIZONTAL = 18; +const PROFILE_SETTING_ROW_PADDING_VERTICAL = 15; +const PROFILE_SETTING_ROW_ICON_SIZE = BrandSpacing.iconContainer; +const PROFILE_SETTING_ROW_SECONDARY_GAP = 5; +const PROFILE_SETTING_ROW_VALUE_GAP = BrandSpacing.inset; +const PROFILE_SETTING_ROW_DIVIDER_LEFT_WITH_ICON = 56; +const PROFILE_SETTING_ROW_DIVIDER_LEFT_WITHOUT_ICON = 18; +const PROFILE_SETTING_ROW_DIVIDER_RIGHT = 18; +const PROFILE_ICON_BUTTON_SIZE = 40; + export function ProfileSectionHeader({ label, description, @@ -22,16 +37,15 @@ export function ProfileSectionHeader({ flush?: boolean; }) { return ( - - - {icon ? : null} + + + {icon ? ( + + ) : null} } onPress={onPress} tone={tone === "accent" ? "primarySubtle" : "secondary"} - size={40} + size={PROFILE_ICON_BUTTON_SIZE} /> ); } @@ -187,18 +201,18 @@ export function ProfileSettingRow({ style={{ flexDirection: "row", alignItems: subtitle && subtitle.length > 36 ? "flex-start" : "center", - gap: BrandSpacing.md + 2, // 14px - paddingHorizontal: BrandSpacing.md + 6, // 18px - paddingVertical: BrandSpacing.md + 3, // 15px + gap: PROFILE_SETTING_ROW_GAP, + paddingHorizontal: PROFILE_SETTING_ROW_PADDING_HORIZONTAL, + paddingVertical: PROFILE_SETTING_ROW_PADDING_VERTICAL, backgroundColor: rowBackground, }} > {icon ? ( ) : null} - + @@ -253,9 +269,9 @@ export function ProfileSettingRow({ style={{ height: 1, marginLeft: icon - ? BrandSpacing.iconContainer + BrandSpacing.md + 6 - : BrandSpacing.md + 6, // 38 + 12 + 6 = 56 : 18 - marginRight: BrandSpacing.md + 6, // 18px + ? PROFILE_SETTING_ROW_DIVIDER_LEFT_WITH_ICON + : PROFILE_SETTING_ROW_DIVIDER_LEFT_WITHOUT_ICON, + marginRight: PROFILE_SETTING_ROW_DIVIDER_RIGHT, backgroundColor: borderColor, }} /> diff --git a/src/components/profile/profile-social-links.tsx b/src/components/profile/profile-social-links.tsx index eb77d7e..194660b 100644 --- a/src/components/profile/profile-social-links.tsx +++ b/src/components/profile/profile-social-links.tsx @@ -3,6 +3,7 @@ import { View } from "react-native"; import { KitSocialIconButton } from "@/components/ui/kit"; import type { BrandPalette } from "@/constants/brand"; +import { BrandSpacing } from "@/constants/brand"; export const PROFILE_SOCIAL_FIELDS = [ { @@ -67,7 +68,7 @@ function toOpenableUrl(key: ProfileSocialKey, value: string) { export function ProfileSocialLinksRow({ socialLinks, palette, - iconSize = 36, + iconSize = BrandSpacing.iconContainer - BrandSpacing.xs / 2, }: { socialLinks: ProfileSocialLinks | undefined; palette: BrandPalette; @@ -80,7 +81,9 @@ export function ProfileSocialLinksRow({ } return ( - + {activeFields.map((field) => ( + ), padding: { - vertical: BrandSpacing.sm, - horizontal: BrandSpacing.lg, + vertical: BrandSpacing.stackTight, + horizontal: BrandSpacing.inset, }, steps: [0.12], initialStep: 0, @@ -213,7 +219,14 @@ export function ProfileSubpageSheetHost({ backgroundColor: accentColor, topInsetColor: accentColor, }; - }, [activeRoute, accessoryContext?.accessories, palette.primary, palette.didit.accent, palette.payments.accent, router]); + }, [ + activeRoute, + accessoryContext?.accessories, + palette.primary, + palette.didit.accent, + palette.payments.accent, + router, + ]); useGlobalTopSheet("profile", config, ownerId); @@ -231,8 +244,8 @@ type ProfileSubpageScrollViewProps = Omit< export function ProfileSubpageScrollView({ contentContainerStyle, - topSpacing = BrandSpacing.lg, - bottomSpacing = BrandSpacing.xl, + topSpacing = PROFILE_SUBPAGE_SCROLL_TOP_SPACING, + bottomSpacing = PROFILE_SUBPAGE_SCROLL_BOTTOM_SPACING, ...props }: ProfileSubpageScrollViewProps) { const collapsedSheetHeight = useCollapsedSheetHeight(); @@ -254,8 +267,8 @@ export function ProfileSubpageScrollView({ export function ProfileIndexScrollView({ contentContainerStyle, - topSpacing = BrandSpacing.lg, - bottomSpacing = BrandSpacing.xl, + topSpacing = PROFILE_SUBPAGE_SCROLL_TOP_SPACING, + bottomSpacing = PROFILE_SUBPAGE_SCROLL_BOTTOM_SPACING, ...props }: ProfileSubpageScrollViewProps) { const collapsedSheetHeight = useCollapsedSheetHeight(); @@ -268,7 +281,6 @@ export function ProfileIndexScrollView({ { paddingTop: collapsedSheetHeight + topSpacing, paddingBottom: bottomSpacing + safeBottom, - paddingHorizontal: 0, }, contentContainerStyle, ]} @@ -277,15 +289,8 @@ export function ProfileIndexScrollView({ } const styles = StyleSheet.create({ - headerRow: { - minHeight: 44, - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - gap: BrandSpacing.sm, - }, edgeSlot: { - minWidth: 40, + minWidth: PROFILE_SUBPAGE_EDGE_SLOT_MIN_WIDTH, alignItems: "flex-start", justifyContent: "center", }, diff --git a/src/components/profile/profile-tab/profile-desktop-hero-panel.tsx b/src/components/profile/profile-tab/profile-desktop-hero-panel.tsx index a71f1e5..4d95e30 100644 --- a/src/components/profile/profile-tab/profile-desktop-hero-panel.tsx +++ b/src/components/profile/profile-tab/profile-desktop-hero-panel.tsx @@ -4,7 +4,7 @@ import { ActionButton } from "@/components/ui/action-button"; import { KitStatusBadge } from "@/components/ui/kit"; import { ProfileAvatar } from "@/components/ui/profile-avatar"; import type { BrandPalette } from "@/constants/brand"; -import { BrandType } from "@/constants/brand"; +import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; import type { ProfileHeroAction } from "./profile-hero-utils"; type ProfileDesktopHeroPanelProps = { @@ -35,23 +35,25 @@ export const ProfileDesktopHeroPanel = memo(function ProfileDesktopHeroPanel({ return ( - + - + - + - + - + {profileName} - + {resolvedStatusLabel ? ( } + icon={ + + } /> diff --git a/src/components/profile/sports-multi-select.tsx b/src/components/profile/sports-multi-select.tsx index d2a194c..d81ca65 100644 --- a/src/components/profile/sports-multi-select.tsx +++ b/src/components/profile/sports-multi-select.tsx @@ -19,6 +19,22 @@ type SportsMultiSelectProps = { variant?: "card" | "content"; }; +const SPORTS_HEADER_HORIZONTAL_PADDING = BrandSpacing.lg; +const SPORTS_HEADER_VERTICAL_PADDING = BrandSpacing.componentPadding; +const SPORTS_HEADER_BADGE_HORIZONTAL_PADDING = BrandSpacing.sm; +const SPORTS_HEADER_BADGE_VERTICAL_PADDING = BrandSpacing.xs; +const SPORTS_PANEL_HORIZONTAL_PADDING = BrandSpacing.componentPadding; +const SPORTS_PANEL_BOTTOM_PADDING = BrandSpacing.componentPadding; +const SPORTS_PANEL_GAP = BrandSpacing.md; +const SPORTS_SECTION_GAP = BrandSpacing.sm; +const SPORTS_RESULT_ROW_MIN_HEIGHT = BrandSpacing.controlLg + BrandSpacing.xs; +const SPORTS_RESULT_ROW_PADDING_HORIZONTAL = BrandSpacing.md; +const SPORTS_RESULT_ROW_PADDING_VERTICAL = BrandSpacing.md; +const SPORTS_RESULT_ROW_GAP = BrandSpacing.md; +const SPORTS_RESULT_EMPTY_GAP = BrandSpacing.xs; +const SPORTS_SELECTED_SPORT_GAP = BrandSpacing.xs / 2; +const SPORTS_RESULTS_MAX_HEIGHT = 260; + export function SportsMultiSelect({ palette, selectedSports, @@ -72,7 +88,7 @@ export function SportsMultiSelect({ }, ]} > - + {isSportType(sport) ? toSportLabel(sport) : sport} @@ -200,13 +216,13 @@ export function SportsMultiSelect({ const styles = StyleSheet.create({ shell: { - borderRadius: BrandRadius.card, + borderRadius: BrandRadius.soft, borderCurve: "continuous", overflow: "hidden", }, header: { - paddingHorizontal: BrandSpacing.lg, // 16px - paddingVertical: BrandSpacing.md + 2, // 14px + paddingHorizontal: SPORTS_HEADER_HORIZONTAL_PADDING, + paddingVertical: SPORTS_HEADER_VERTICAL_PADDING, flexDirection: "row", alignItems: "center", justifyContent: "space-between", @@ -219,20 +235,20 @@ const styles = StyleSheet.create({ headerBadge: { borderRadius: BrandRadius.pill, borderCurve: "continuous", - paddingHorizontal: BrandSpacing.sm + 2, // 10px - paddingVertical: BrandSpacing.xs + 2, // 6px + paddingHorizontal: SPORTS_HEADER_BADGE_HORIZONTAL_PADDING, + paddingVertical: SPORTS_HEADER_BADGE_VERTICAL_PADDING, }, panel: { - paddingHorizontal: BrandSpacing.componentPadding, // 14px - paddingBottom: BrandSpacing.componentPadding, // 14px - gap: BrandSpacing.md, + paddingHorizontal: SPORTS_PANEL_HORIZONTAL_PADDING, + paddingBottom: SPORTS_PANEL_BOTTOM_PADDING, + gap: SPORTS_PANEL_GAP, }, panelContentOnly: { - paddingHorizontal: 0, - paddingBottom: 0, + paddingHorizontal: BrandSpacing.xs - BrandSpacing.xs, + paddingBottom: BrandSpacing.xs - BrandSpacing.xs, }, section: { - gap: BrandSpacing.sm, + gap: SPORTS_SECTION_GAP, }, sectionLabel: { ...BrandType.micro, @@ -240,20 +256,20 @@ const styles = StyleSheet.create({ textTransform: "uppercase", }, resultsViewport: { - maxHeight: 260, + maxHeight: SPORTS_RESULTS_MAX_HEIGHT, }, resultsList: { - gap: BrandSpacing.sm, + gap: SPORTS_SECTION_GAP, }, resultRow: { - minHeight: BrandSpacing.iconContainer + 18, // 56px - touch target friendly - borderRadius: BrandRadius.cardSubtle, // card - 6 = 18px + minHeight: SPORTS_RESULT_ROW_MIN_HEIGHT, + borderRadius: BrandRadius.medium, borderCurve: "continuous", - paddingHorizontal: BrandSpacing.md, - paddingVertical: BrandSpacing.md, + paddingHorizontal: SPORTS_RESULT_ROW_PADDING_HORIZONTAL, + paddingVertical: SPORTS_RESULT_ROW_PADDING_VERTICAL, flexDirection: "row", alignItems: "center", - gap: BrandSpacing.md, + gap: SPORTS_RESULT_ROW_GAP, }, resultTitle: { ...BrandType.bodyStrong, @@ -262,10 +278,10 @@ const styles = StyleSheet.create({ ...BrandType.micro, }, emptyState: { - borderRadius: BrandRadius.cardSubtle, + borderRadius: BrandRadius.medium, borderCurve: "continuous", - paddingHorizontal: BrandSpacing.md, - paddingVertical: BrandSpacing.md, - gap: BrandSpacing.xs, + paddingHorizontal: SPORTS_RESULT_ROW_PADDING_HORIZONTAL, + paddingVertical: SPORTS_RESULT_ROW_PADDING_VERTICAL, + gap: SPORTS_RESULT_EMPTY_GAP, }, }); diff --git a/src/components/profile/status-signal.tsx b/src/components/profile/status-signal.tsx index 5c9c181..39af908 100644 --- a/src/components/profile/status-signal.tsx +++ b/src/components/profile/status-signal.tsx @@ -13,6 +13,11 @@ export type StatusSignalProps = { icon?: ReactNode; }; +const STATUS_SIGNAL_MIN_HEIGHT = 44; +const STATUS_SIGNAL_HORIZONTAL_PADDING = BrandSpacing.controlX; +const STATUS_SIGNAL_VERTICAL_PADDING = BrandSpacing.controlY; +const STATUS_SIGNAL_CONTENT_GAP = 2; + export function StatusSignal({ label, value, palette, tone = "surface", icon }: StatusSignalProps) { const backgroundColor = tone === "accent" ? (palette.primarySubtle as string) : (palette.surfaceElevated as string); @@ -26,7 +31,7 @@ export function StatusSignal({ label, value, palette, tone = "surface", icon }: styles.inner, { backgroundColor, - borderRadius: BrandRadius.cardSubtle, + borderRadius: BrandRadius.medium, borderCurve: "continuous", }, ]} @@ -66,13 +71,14 @@ const styles = StyleSheet.create({ inner: { flexDirection: "row", alignItems: "center", - paddingHorizontal: BrandSpacing.componentPadding, - paddingVertical: BrandSpacing.md, + minHeight: STATUS_SIGNAL_MIN_HEIGHT, + paddingHorizontal: STATUS_SIGNAL_HORIZONTAL_PADDING, + paddingVertical: STATUS_SIGNAL_VERTICAL_PADDING, gap: BrandSpacing.sm, }, content: { flex: 1, - gap: 2, + gap: STATUS_SIGNAL_CONTENT_GAP, minWidth: 0, }, label: { diff --git a/src/components/ui/kit/kit-button-group.tsx b/src/components/ui/kit/kit-button-group.tsx index 10d24af..ee2fd01 100644 --- a/src/components/ui/kit/kit-button-group.tsx +++ b/src/components/ui/kit/kit-button-group.tsx @@ -42,9 +42,27 @@ const SIZE_PRESET: Record< KitButtonGroupSize, { minHeight: number; radius: number; paddingX: number; inset: number; separatorInset: number } > = { - sm: { minHeight: BrandSpacing.iconContainer, radius: BrandRadius.buttonSubtle, paddingX: BrandSpacing.componentPadding, inset: 2, separatorInset: BrandSpacing.sm + 1 }, - md: { minHeight: BrandSpacing.iconContainer, radius: BrandRadius.button, paddingX: BrandSpacing.lg, inset: 3, separatorInset: BrandSpacing.sm + 3 }, - lg: { minHeight: BrandSpacing.xxl + 6, radius: BrandRadius.button, paddingX: BrandSpacing.xl - 2, inset: 3, separatorInset: BrandSpacing.sm + 4 }, + sm: { + minHeight: BrandSpacing.iconContainer, + radius: BrandRadius.buttonSubtle, + paddingX: BrandSpacing.componentPadding, + inset: 2, + separatorInset: BrandSpacing.sm + 1, + }, + md: { + minHeight: BrandSpacing.iconContainer, + radius: BrandRadius.button, + paddingX: BrandSpacing.lg, + inset: 3, + separatorInset: BrandSpacing.sm + 3, + }, + lg: { + minHeight: BrandSpacing.xxl + 6, + radius: BrandRadius.button, + paddingX: BrandSpacing.xl - 2, + inset: 3, + separatorInset: BrandSpacing.sm + 4, + }, }; export function KitButtonGroup({ @@ -72,16 +90,24 @@ export function KitButtonGroup({ const wraps = resolvedColumns < options.length; const slotBasis = `${100 / resolvedColumns}%` as DimensionValue; - const resolvedGroupBg = groupBackgroundColor ?? (tone === "onPrimary" ? `${String(palette.text)}CC` : String(palette.surfaceAlt)); - const resolvedSelectedBg = selectedBackgroundColor ?? (tone === "onPrimary" ? `${String(palette.onPrimary)}33` : String(palette.surfaceElevated)); - const resolvedLabelColorFinal = labelColor ?? (tone === "onPrimary" ? `${String(palette.onPrimary)}B8` : String(palette.textMuted)); + const resolvedGroupBg = + groupBackgroundColor ?? + (tone === "onPrimary" ? `${String(palette.text)}CC` : String(palette.surfaceAlt)); + const resolvedSelectedBg = + selectedBackgroundColor ?? + (tone === "onPrimary" ? `${String(palette.onPrimary)}33` : String(palette.surfaceElevated)); + const resolvedLabelColorFinal = + labelColor ?? + (tone === "onPrimary" ? `${String(palette.onPrimary)}B8` : String(palette.textMuted)); const resolvedSelectedLabelColorFinal = selectedLabelColor ?? String(palette.onPrimary); - const resolvedDividerColorFinal = dividerColor ?? (tone === "onPrimary" ? `${String(palette.onPrimary)}24` : String(palette.borderStrong)); + const resolvedDividerColorFinal = + dividerColor ?? + (tone === "onPrimary" ? `${String(palette.onPrimary)}24` : String(palette.borderStrong)); return ( ({ alignSelf: fullWidth ? "stretch" : alignSelfMap[align], backgroundColor: resolvedGroupBg, flexWrap: wraps ? "wrap" : "nowrap", - borderRadius: BrandRadius.button, padding: BrandSpacing.sm - 2, }, style, @@ -169,7 +194,9 @@ export function KitButtonGroup({ }, ]} > - {option.icon ? {option.icon} : null} + {option.icon ? ( + {option.icon} + ) : null} [ { - minHeight: BrandSpacing.iconContainer, - paddingHorizontal: BrandSpacing.componentPadding, - paddingVertical: BrandSpacing.sm, backgroundColor: selected ? (palette.primary as string) : (palette.surfaceAlt as string), opacity: disabled ? 0.72 : 1, transform: [{ scale: pressed && !disabled ? 0.985 : 1 }], - borderRadius: BrandRadius.buttonSubtle, }, style, ]} diff --git a/src/components/ui/kit/kit-disclosure-button-group.tsx b/src/components/ui/kit/kit-disclosure-button-group.tsx index 0bf06cf..e908ac1 100644 --- a/src/components/ui/kit/kit-disclosure-button-group.tsx +++ b/src/components/ui/kit/kit-disclosure-button-group.tsx @@ -86,13 +86,12 @@ export function KitDisclosureButtonGroup({ return ( ({ {selected ? ( @@ -148,7 +146,7 @@ export function KitDisclosureButtonGroup({ style={({ pressed }) => [{ opacity: pressed ? 0.9 : 1 }]} > ({ } satisfies ViewStyle, ]} > - {option.icon ? {option.icon} : null} + {option.icon ? ( + {option.icon} + ) : null} ({ ]} > diff --git a/src/components/ui/kit/kit-mesh-gradient.tsx b/src/components/ui/kit/kit-mesh-gradient.tsx index cf13bfa..9418f80 100644 --- a/src/components/ui/kit/kit-mesh-gradient.tsx +++ b/src/components/ui/kit/kit-mesh-gradient.tsx @@ -3,6 +3,7 @@ import { Pressable, StyleSheet, View, type ViewProps } from "react-native"; import Svg, { Defs, Pattern, Rect } from "react-native-svg"; import type { MeshGradientPreset } from "@/constants/brand"; import { BrandMeshGradient } from "@/constants/brand"; +import { useBrand } from "@/hooks/use-brand"; import { useThemePreference } from "@/hooks/use-theme-preference"; type MeshGradientViewProps = ViewProps & { @@ -18,19 +19,17 @@ type MeshGradientViewProps = ViewProps & { darkVariant?: boolean; }; -/** - * TexturedOverlay - Subtle dot pattern for texture/grain feel - * Uses a repeating SVG pattern of tiny semi-transparent dots. - * Works on both native and web platforms. - */ -function TexturedOverlay() { +type TexturedOverlayProps = { + tintColor: string; +}; + +function TexturedOverlay({ tintColor }: TexturedOverlayProps) { return ( - {/* Tiny dot pattern for subtle grain texture */} - - + + @@ -55,6 +54,7 @@ export function MeshGradientView({ children, ...props }: MeshGradientViewProps) { + const palette = useBrand(); const { resolvedScheme } = useThemePreference(); const { gradient, grainOpacity: defaultGrainOpacity } = useMemo(() => { @@ -63,6 +63,7 @@ export function MeshGradientView({ }, [resolvedScheme, preset, darkVariant]); const effectiveGrainOpacity = grainOpacity ?? defaultGrainOpacity; + const tintColor = palette.onPrimary as string; const containerStyle = useMemo( () => [ @@ -83,7 +84,7 @@ export function MeshGradientView({ style={[styles.textureOverlay, { opacity: effectiveGrainOpacity }]} pointerEvents="none" > - + ); @@ -98,7 +99,7 @@ export function MeshGradientView({ style={[styles.textureOverlay, { opacity: effectiveGrainOpacity }]} pointerEvents="none" > - + )} diff --git a/src/components/ui/kit/kit-social-icon-button.tsx b/src/components/ui/kit/kit-social-icon-button.tsx index 80bdfe1..1246e44 100644 --- a/src/components/ui/kit/kit-social-icon-button.tsx +++ b/src/components/ui/kit/kit-social-icon-button.tsx @@ -2,7 +2,7 @@ import FontAwesome5 from "@expo/vector-icons/FontAwesome5"; import { Pressable, View } from "react-native"; import { AppSymbol } from "@/components/ui/app-symbol"; -import type { BrandPalette } from "@/constants/brand"; +import { type BrandPalette, BrandRadius, BrandSpacing } from "@/constants/brand"; import { triggerSelectionHaptic } from "./native-interaction"; type BrandIconName = "instagram" | "tiktok" | "whatsapp" | "facebook" | "linkedin"; @@ -22,15 +22,15 @@ export function KitSocialIconButton({ palette, onPress, active = true, - size = 36, + size = BrandSpacing.controlSm - BrandSpacing.xxs, }: KitSocialIconButtonProps) { - const iconSize = Math.max(14, Math.round(size * 0.44)); + const iconSize = Math.max(BrandSpacing.md + BrandSpacing.xxs, Math.round(size * 0.44)); const circle = ( ({ - borderRadius: 999, + borderRadius: BrandRadius.pill, opacity: pressed ? 0.84 : 1, })} > diff --git a/src/components/ui/native-search-field.tsx b/src/components/ui/native-search-field.tsx index 6e1eae4..48dd735 100644 --- a/src/components/ui/native-search-field.tsx +++ b/src/components/ui/native-search-field.tsx @@ -7,26 +7,25 @@ import { type ViewStyle, } from "react-native"; import Animated, { LinearTransition, ReduceMotion } from "react-native-reanimated"; -import { BrandRadius, BrandSpacing } from "@/constants/brand"; +import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; import { useBrand } from "@/hooks/use-brand"; import { useThemePreference } from "@/hooks/use-theme-preference"; -// Search field sizes - sm for inline/compact use, md for standard use const SEARCH_SIZE_SM = { - containerMinHeight: BrandSpacing.iconContainer + BrandSpacing.sm, // 38 + 8 = 46px - inputMinHeight: BrandSpacing.iconContainer + BrandSpacing.xs, // 38 + 4 = 42px + containerMinHeight: BrandSpacing.controlSm + BrandSpacing.sm, + inputMinHeight: BrandSpacing.controlSm + BrandSpacing.xxs + BrandSpacing.xxs, horizontalPadding: BrandSpacing.md, - iconSize: BrandSpacing.md + BrandSpacing.xs, // 12 + 4 = 16px - clearIconSize: BrandSpacing.md + BrandSpacing.xs - 1, // 12 + 4 - 1 = 15px - radius: BrandRadius.cardSubtle - BrandSpacing.xs, // 18 - 4 = 14px + iconSize: BrandSpacing.iconSm - BrandSpacing.xxs, + clearIconSize: BrandSpacing.iconSm - BrandSpacing.xxs, + radius: BrandRadius.buttonSubtle, } as const; const SEARCH_SIZE_MD = { - containerMinHeight: BrandSpacing.iconContainer + BrandSpacing.md, // 38 + 12 = 50px - inputMinHeight: BrandSpacing.iconContainer + BrandSpacing.sm, // 38 + 8 = 46px + containerMinHeight: BrandSpacing.controlSm + BrandSpacing.md, + inputMinHeight: BrandSpacing.controlSm + BrandSpacing.sm, horizontalPadding: BrandSpacing.lg, - iconSize: BrandSpacing.md + BrandSpacing.sm, // 12 + 8 = 20px - clearIconSize: BrandSpacing.md + BrandSpacing.xs, // 12 + 4 = 16px + iconSize: BrandSpacing.iconSm + BrandSpacing.xxs, + clearIconSize: BrandSpacing.iconSm - BrandSpacing.xxs, radius: BrandRadius.input, } as const; @@ -91,11 +90,9 @@ export function NativeSearchField({ { flex: 1, minHeight: metrics.inputMinHeight, + ...BrandType.bodyMedium, color: palette.text, - fontSize: 16, - fontWeight: "500", includeFontPadding: false, - paddingVertical: 0, }, style, ]} diff --git a/src/components/ui/sheet-header-block.tsx b/src/components/ui/sheet-header-block.tsx index a956b5e..44a60e7 100644 --- a/src/components/ui/sheet-header-block.tsx +++ b/src/components/ui/sheet-header-block.tsx @@ -1,7 +1,7 @@ import { I18nManager, Pressable, View } from "react-native"; import { ThemedText } from "@/components/themed-text"; -import { BrandRadius, BrandSpacing } from "@/constants/brand"; +import { BrandSpacing } from "@/constants/brand"; import { useBrand } from "@/hooks/use-brand"; type SheetHeaderBlockProps = { @@ -45,21 +45,18 @@ export function SheetHeaderBlock({ : (palette.text as string); return ( - + {progressCount && progressIndex ? ( {Array.from({ length: progressCount }, (_, index) => { @@ -68,10 +65,10 @@ export function SheetHeaderBlock({ return ( {trailingIcon} @@ -115,7 +109,7 @@ export function SheetHeaderBlock({ ) : null} - + {title} diff --git a/src/constants/brand.ts b/src/constants/brand.ts index ffff157..1c54d42 100644 --- a/src/constants/brand.ts +++ b/src/constants/brand.ts @@ -16,6 +16,8 @@ export type BrandPalette = { text: ColorValue; textMuted: ColorValue; textMicro: ColorValue; + onPrimaryShadowStrong: ColorValue; + onPrimaryShadowSoft: ColorValue; // Brand primary: ColorValue; primarySubtle: ColorValue; @@ -67,6 +69,8 @@ const ExplicitBrandPalette: Record = { text: "#181522", textMuted: "#6D6580", textMicro: "#8E86A0", + onPrimaryShadowStrong: "#00000052", + onPrimaryShadowSoft: "#0000003D", primary: "#8B5CF6", primarySubtle: "#E8DDFF", primaryPressed: "#7443F0", @@ -113,6 +117,8 @@ const ExplicitBrandPalette: Record = { text: "#F7F4FE", textMuted: "#B8B0CA", textMicro: "#8C849E", + onPrimaryShadowStrong: "#00000052", + onPrimaryShadowSoft: "#0000003D", primary: "#8F6AFB", primarySubtle: "#36285C", primaryPressed: "#7A55F3", @@ -212,17 +218,21 @@ export const MapBrandPalette = NativeMapBrandPalette; // ─── Spacing & Radius ──────────────────────────────────────────────────────── export const BrandRadius = { + hard: 12, + medium: 18, + soft: 24, + pill: 999, card: 24, cardSubtle: 18, button: 20, buttonSubtle: 14, input: 20, - pill: 999, icon: 999, circle: 999, } as const; export const BrandSpacing = { + xxs: 2, xs: 4, sm: 8, md: 12, @@ -234,7 +244,31 @@ export const BrandSpacing = { iconContainerLarge: 78, haloSize: 180, mapMinHeight: 300, + mapCanvasMinHeight: 320, multilineInputMinHeight: 96, + stackTight: 8, + stack: 12, + stackRoomy: 16, + stackLoose: 24, + insetTight: 12, + inset: 16, + insetRoomy: 24, + section: 32, + controlX: 14, + controlY: 12, + controlSm: 38, + controlMd: 44, + controlLg: 52, + iconSm: 18, + iconMd: 24, + iconLg: 32, + avatarSm: 38, + avatarMd: 48, + avatarLg: 78, + shellRail: 236, + shellPanel: 320, + shellCommandPanel: 360, + statusDot: 6, } as const; // ─── Typography ────────────────────────────────────────────────────────────── diff --git a/src/global.css b/src/global.css index 7dfbed0..98a1240 100644 --- a/src/global.css +++ b/src/global.css @@ -6,6 +6,20 @@ /* ─── Brand Spacing ──────────────────────────────────────────── */ /* These map to Tailwind spacing utilities: gap-lg, px-lg, py-lg, mx-lg, my-lg, etc. */ @theme { + --spacing-xxs: 2px; + --spacing-stack-tight: 8px; + --spacing-stack: 12px; + --spacing-stack-roomy: 16px; + --spacing-stack-loose: 24px; + --spacing-inset-tight: 12px; + --spacing-inset: 16px; + --spacing-inset-roomy: 24px; + --spacing-section: 32px; + --spacing-control-x: 14px; + --spacing-control-y: 12px; + --spacing-control-sm: 38px; + --spacing-control-md: 44px; + --spacing-control-lg: 52px; --spacing-xs: 4px; --spacing-sm: 8px; --spacing-md: 12px; @@ -15,14 +29,22 @@ --spacing-component-padding: 14px; --spacing-icon-container: 38px; --spacing-icon-container-large: 78px; + --spacing-shell-rail: 236px; + --spacing-shell-panel: 320px; + --spacing-shell-command-panel: 360px; --spacing-halo-size: 180px; --spacing-map-min-height: 300px; + --spacing-map-canvas-min-height: 320px; --spacing-multiline-input-min-height: 96px; } /* ─── Brand Radius ───────────────────────────────────────────── */ /* These map to: rounded-card, rounded-button, rounded-pill, etc. */ @theme { + --radius-hard: 12px; + --radius-medium: 18px; + --radius-soft: 24px; + --radius-pill: 999px; --radius-card: 24px; --radius-card-subtle: 18px; --radius-button: 20px; @@ -37,56 +59,56 @@ /* Dynamic runtime colors are injected via CSS variables (set by vars() or VariableContextProvider) */ @theme { /* Brand */ - --color-primary: #8B5CF6; - --color-primary-subtle: #E8DDFF; - --color-primary-pressed: #7443F0; + --color-primary: #8b5cf6; + --color-primary-subtle: #e8ddff; + --color-primary-pressed: #7443f0; /* Semantic */ - --color-success: #169C52; - --color-success-subtle: #DDF7E6; - --color-danger: #D43B4E; - --color-danger-subtle: #FFE3E8; - --color-warning: #D68116; - --color-warning-subtle: #FFF1D8; + --color-success: #169c52; + --color-success-subtle: #ddf7e6; + --color-danger: #d43b4e; + --color-danger-subtle: #ffe3e8; + --color-warning: #d68116; + --color-warning-subtle: #fff1d8; /* Surfaces */ - --color-surface: #FFFFFF; - --color-surface-alt: #F1ECF8; - --color-surface-elevated: #FFFFFF; - --color-app-bg: #F6F4FB; + --color-surface: #ffffff; + --color-surface-alt: #f1ecf8; + --color-surface-elevated: #ffffff; + --color-app-bg: #f6f4fb; /* Borders */ - --color-border: #DDD6EA; - --color-border-strong: #B8AFCB; + --color-border: #ddd6ea; + --color-border-strong: #b8afcb; /* Text */ --color-text: #181522; - --color-text-muted: #6D6580; - --color-text-micro: #8E86A0; + --color-text-muted: #6d6580; + --color-text-micro: #8e86a0; /* Accent (overridable via vars() at runtime) */ - --color-accent: #8B5CF6; - --color-accent-subtle: #E8DDFF; + --color-accent: #8b5cf6; + --color-accent-subtle: #e8ddff; } /* ─── Dark Mode ──────────────────────────────────────────────── */ @media (prefers-color-scheme: dark) { :root { - --color-primary: #8F6AFB; - --color-primary-subtle: #2D2066; - --color-primary-pressed: #A78BFA; + --color-primary: #8f6afb; + --color-primary-subtle: #2d2066; + --color-primary-pressed: #a78bfa; --color-surface: #121020; - --color-surface-alt: #1C1830; + --color-surface-alt: #1c1830; --color-surface-elevated: #252238; - --color-app-bg: #0B0910; + --color-app-bg: #0b0910; - --color-border: #2D2850; - --color-border-strong: #3D3870; + --color-border: #2d2850; + --color-border-strong: #3d3870; - --color-text: #F5F3FF; - --color-text-muted: #A8A0C0; - --color-text-micro: #7870A0; + --color-text: #f5f3ff; + --color-text-muted: #a8a0c0; + --color-text-micro: #7870a0; } } diff --git a/src/i18n/translations/en.ts b/src/i18n/translations/en.ts index 71d68f4..fe4d85e 100644 --- a/src/i18n/translations/en.ts +++ b/src/i18n/translations/en.ts @@ -316,6 +316,8 @@ const en = { futureNote: "Queue syncs to a dedicated device calendar. Cloud sync to Google or Apple depends on your phone account settings.", googleConnectRequired: "Connect your Google account to enable direct Google Calendar sync.", + googleReconnectRequired: + "Reconnect Google Calendar to restore sync. This connection is missing refresh credentials.", googleConnectedAs: "Connected as {{email}}", applePermissionNote: "Apple sync requests calendar access and writes to a dedicated Queue Sessions calendar.", @@ -621,7 +623,8 @@ const en = { cancelled: "Verification flow was cancelled.", invalidReturn: "Didit did not return a valid completion signal.", startFailed: "Failed to start Didit verification.", - nativeUnavailable: "Native verification is not available in this build. Update the app and try again.", + nativeUnavailable: + "Native verification is not available in this build. Update the app and try again.", externalLinkFailed: "Could not open the verification reference link.", slow: "Verification is taking longer than expected. Pull to refresh or try again shortly.", approvedInfo: "Identity verified. Your KYC is now active.", @@ -641,8 +644,7 @@ const en = { documentBody: "Use a passport or national identity document that matches the legal name you use for payouts.", faceTitle: "Use good lighting for the selfie check", - faceBody: - "Didit may ask for a live face scan to confirm the document belongs to you.", + faceBody: "Didit may ask for a live face scan to confirm the document belongs to you.", timeTitle: "Set aside a couple of minutes", timeBody: "The flow is quickest when your document is clean, readable, and your camera is steady.", @@ -779,10 +781,8 @@ const en = { addAccountSubtitle: "Sign in to another account and link it to this login.", addAccountBody: "Use email, magic link, Google, or Apple to connect another existing account to the one you are already using.", - addInstructorAccountBody: - "Sign in to the instructor account you want to link to this login.", - addStudioAccountBody: - "Sign in to the studio account you want to link to this login.", + addInstructorAccountBody: "Sign in to the instructor account you want to link to this login.", + addStudioAccountBody: "Sign in to the studio account you want to link to this login.", addAccountLinked: "Account linked. Finishing setup...", magicLinkUnavailableNative: "Email magic links are not supported in Expo native apps, so this flow uses one-time codes.", diff --git a/src/i18n/translations/he.ts b/src/i18n/translations/he.ts index d61634a..c1fc3ce 100644 --- a/src/i18n/translations/he.ts +++ b/src/i18n/translations/he.ts @@ -293,6 +293,8 @@ const he = { futureNote: "Queue מסנכרנת ליומן ייעודי במכשיר. סנכרון ענן ל-Google או Apple תלוי בהגדרות החשבון במכשיר.", googleConnectRequired: "חברו חשבון Google כדי להפעיל סנכרון ישיר ל-Google Calendar.", + googleReconnectRequired: + "חברו מחדש את Google Calendar כדי לשחזר את הסנכרון. לחיבור הזה חסרות הרשאות רענון.", googleConnectedAs: "מחובר כ-{{email}}", applePermissionNote: "סנכרון Apple יבקש הרשאת יומן וישמור אירועים ביומן Queue Sessions ייעודי.", @@ -599,14 +601,11 @@ const he = { prepTitle: "מה להכין מראש", prep: { documentTitle: "הכינו מסמך ממשלתי אמיתי", - documentBody: - "השתמשו בדרכון או בתעודת זהות שתואמים לשם החוקי שמשמש למשיכות.", + documentBody: "השתמשו בדרכון או בתעודת זהות שתואמים לשם החוקי שמשמש למשיכות.", faceTitle: "השתמשו בתאורה טובה לסריקת הפנים", - faceBody: - "Didit עשויה לבקש בדיקת סלפי או חיות כדי לוודא שהמסמך שייך לכם.", + faceBody: "Didit עשויה לבקש בדיקת סלפי או חיות כדי לוודא שהמסמך שייך לכם.", timeTitle: "פנו כמה דקות", - timeBody: - "הזרימה מהירה יותר כשהמסמך נקי, קריא, והמצלמה יציבה.", + timeBody: "הזרימה מהירה יותר כשהמסמך נקי, קריא, והמצלמה יציבה.", }, stepsTitle: "מה הבדיקה עושה", steps: { @@ -628,8 +627,7 @@ const he = { "כי Queue תומכת בפעילות בתשלום ובהגדרת משיכות, אנחנו צריכים לוודא מי מקבל כסף ולצמצם סיכוני הונאה לפני הפעלת משיכות.", why: { payoutsTitle: "משיכות דורשות זהות מאומתת", - payoutsBody: - "כך אנחנו קושרים משיכות ובדיקות חשבון לאדם אמיתי במקום לפרופיל אנונימי.", + payoutsBody: "כך אנחנו קושרים משיכות ובדיקות חשבון לאדם אמיתי במקום לפרופיל אנונימי.", fraudTitle: "בקרות הונאה בתשלומים בישראל מתחזקות", fraudBody: "בדיקות זהות עוזרות לצמצם השתלטות על חשבונות, כרטיסים גנובים, וניצול לרעה סביב תשלומים דיגיטליים.", @@ -662,8 +660,7 @@ const he = { in_review: "Didit קיבלה את ההגשה שלכם ובודקת אותה כעת. המסך הזה ימשיך לעקוב אחרי שתחזרו מהזרימה.", pending: "ההגשה התקבלה. אנחנו מחכים לתוצאת בדיקה סופית מ-Didit.", - in_progress: - "הניסיון האחרון לא הושלם. התחילו אימות מאובטח חדש כדי להמשיך.", + in_progress: "הניסיון האחרון לא הושלם. התחילו אימות מאובטח חדש כדי להמשיך.", abandoned: "זרימת האימות בוטלה לפני השלמה. התחילו שוב כשתהיו מוכנים.", expired: "תוקף סשן האימות פג. התחילו סשן חדש כדי להמשיך.", default: "השלימו אימות זהות כדי לפתוח KYC וגישה למשיכות.", @@ -745,10 +742,8 @@ const he = { addAccountSubtitle: "התחברו לחשבון נוסף וקשרו אותו לחיבור הנוכחי.", addAccountBody: "השתמשו באימייל, קישור קסם, Google או Apple כדי לחבר חשבון קיים נוסף לחשבון שבו אתם כבר משתמשים.", - addInstructorAccountBody: - "התחברו לחשבון המדריך/ה שברצונכם לקשר לחיבור הזה.", - addStudioAccountBody: - "התחברו לחשבון הסטודיו שברצונכם לקשר לחיבור הזה.", + addInstructorAccountBody: "התחברו לחשבון המדריך/ה שברצונכם לקשר לחיבור הזה.", + addStudioAccountBody: "התחברו לחשבון הסטודיו שברצונכם לקשר לחיבור הזה.", addAccountLinked: "החשבון קושר. משלימים את ההגדרה...", magicLinkUnavailableNative: "Email magic links are not supported in Expo native apps, so this flow uses one-time codes.", diff --git a/src/lib/device-calendar-sync.ts b/src/lib/device-calendar-sync.ts index b5480f8..4f72adb 100644 --- a/src/lib/device-calendar-sync.ts +++ b/src/lib/device-calendar-sync.ts @@ -1,6 +1,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import * as Calendar from "expo-calendar"; import { Platform } from "react-native"; +import { Brand } from "@/constants/brand"; const QUEUE_CALENDAR_TITLE = "Queue Sessions"; const STORAGE_KEY = "calendar:device-sync:v1"; @@ -69,7 +70,7 @@ async function createQueueCalendar() { if (!sourceId) return null; return Calendar.createCalendarAsync({ title: QUEUE_CALENDAR_TITLE, - color: "#2A6CF0", + color: String(Brand.primary), entityType: Calendar.EntityTypes.EVENT, sourceId, name: QUEUE_CALENDAR_TITLE, @@ -80,7 +81,7 @@ async function createQueueCalendar() { return Calendar.createCalendarAsync({ title: QUEUE_CALENDAR_TITLE, - color: "#2A6CF0", + color: String(Brand.primary), entityType: Calendar.EntityTypes.EVENT, name: QUEUE_CALENDAR_TITLE, ownerAccount: "personal", diff --git a/src/modules/navigation/role-tabs-layout.web.tsx b/src/modules/navigation/role-tabs-layout.web.tsx index 73cc08d..f0fe154 100644 --- a/src/modules/navigation/role-tabs-layout.web.tsx +++ b/src/modules/navigation/role-tabs-layout.web.tsx @@ -5,6 +5,7 @@ import { Pressable, Text, View } from "react-native"; import { GlobalTopSheet } from "@/components/layout/global-top-sheet"; import { ScrollSheetProvider } from "@/components/layout/scroll-sheet-provider"; import { GlobalTopSheetProvider } from "@/components/layout/top-sheet-registry"; +import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; import { TabBarScrollProvider } from "@/contexts/tab-bar-scroll-context"; import { useBrand } from "@/hooks/use-brand"; import { buildRoleTabRoute, type RoleTabRouteName } from "@/navigation/role-routes"; @@ -50,40 +51,36 @@ export function RoleTabsLayout({ appRole, badgeCountByRoute }: RoleTabsLayoutPro style={{ flex: 1, flexDirection: "row", - gap: 16, - paddingHorizontal: 16, - paddingVertical: 16, + gap: BrandSpacing.lg, + paddingHorizontal: BrandSpacing.lg, + paddingVertical: BrandSpacing.lg, }} > - + Queue @@ -91,8 +88,7 @@ export function RoleTabsLayout({ appRole, badgeCountByRoute }: RoleTabsLayoutPro @@ -100,7 +96,7 @@ export function RoleTabsLayout({ appRole, badgeCountByRoute }: RoleTabsLayoutPro - + {tabs.map((tab) => { const route = buildRoleTabRoute(appRole, tab.routeName) as Href; const selected = activeTab?.id === tab.id; @@ -111,13 +107,13 @@ export function RoleTabsLayout({ appRole, badgeCountByRoute }: RoleTabsLayoutPro ({ - borderRadius: 22, + borderRadius: BrandRadius.medium, borderCurve: "continuous", backgroundColor: selected ? (palette.text as string) : (palette.surfaceAlt as string), - paddingHorizontal: 14, - paddingVertical: 12, + paddingHorizontal: BrandSpacing.componentPadding, + paddingVertical: BrandSpacing.md, transform: [{ scale: pressed ? 0.99 : 1 }], })} > @@ -126,15 +122,13 @@ export function RoleTabsLayout({ appRole, badgeCountByRoute }: RoleTabsLayoutPro flexDirection: "row", alignItems: "center", justifyContent: "space-between", - gap: 12, + gap: BrandSpacing.md, }} > - + {selected ? "Current workspace" : "Open workspace"} @@ -156,20 +151,19 @@ export function RoleTabsLayout({ appRole, badgeCountByRoute }: RoleTabsLayoutPro {badgeCount > 0 ? ( - + - + Workspace {t(activeTab?.titleKey ?? "tabs.home")} - + Today @@ -254,7 +241,7 @@ export function RoleTabsLayout({ appRole, badgeCountByRoute }: RoleTabsLayoutPro style={{ flex: 1, minHeight: 0, - borderRadius: 30, + borderRadius: BrandRadius.soft, borderCurve: "continuous", backgroundColor: palette.surface as string, overflow: "hidden", diff --git a/src/tw/image.tsx b/src/tw/image.tsx index 0a96e77..1b4fb58 100644 --- a/src/tw/image.tsx +++ b/src/tw/image.tsx @@ -1,15 +1,23 @@ -import { useCssElement } from "react-native-css"; -import React from "react"; +import { Image as RNImage } from "expo-image"; +import type React from "react"; import { StyleSheet } from "react-native"; +import { useCssElement } from "react-native-css"; import Animated from "react-native-reanimated"; -import { Image as RNImage } from "expo-image"; const AnimatedExpoImage = Animated.createAnimatedComponent(RNImage); // eslint-disable-next-line @typescript-eslint/no-explicit-any function CSSImage(props: any) { const { objectFit, objectPosition, ...style } = StyleSheet.flatten(props.style) || {}; - return ; + return ( + + ); } export const Image = (props: React.ComponentProps & { className?: string }) => { From 78523e23e686a1b11d0c0ea53c60f92a3b172593 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 23 Mar 2026 18:45:53 +0200 Subject: [PATCH 05/44] Reduce layout animation overhead in feed surfaces --- src/components/jobs/instructor-feed.tsx | 26 +++++++---------------- src/components/ui/native-search-field.tsx | 7 +++--- src/tw/image.tsx | 12 ++++++++++- 3 files changed, 22 insertions(+), 23 deletions(-) diff --git a/src/components/jobs/instructor-feed.tsx b/src/components/jobs/instructor-feed.tsx index 9c34acd..70a7460 100644 --- a/src/components/jobs/instructor-feed.tsx +++ b/src/components/jobs/instructor-feed.tsx @@ -4,7 +4,6 @@ import { Redirect, useRouter } from "expo-router"; import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { RefreshControl, StyleSheet, View } from "react-native"; -import Animated, { LinearTransition, ReduceMotion } from "react-native-reanimated"; import { InstructorOpenJobsList } from "@/components/jobs/instructor/instructor-open-jobs-list"; import { NoticeBanner } from "@/components/jobs/notice-banner"; import { TabScreenScrollView } from "@/components/layout/tab-screen-scroll-view"; @@ -123,27 +122,19 @@ export function InstructorFeed() { ] as const satisfies readonly KitDisclosureButtonGroupOption<"all" | "24h" | "72h">[], [t], ); - const headerLayoutTransition = useMemo( - () => LinearTransition.duration(220).reduceMotion(ReduceMotion.System), - [], - ); const jobsSheetConfig = useMemo( () => ({ stickyHeader: ( - - + - + - - + + - - + + {applyErrorMessage ? ( setApplyErrorMessage(null)} /> ) : null} - + ), padding: { vertical: BrandSpacing.sm, @@ -204,7 +195,6 @@ export function InstructorFeed() { jobsFilterOptions, jobsWindowFilter, jobsSearchQuery, - headerLayoutTransition, palette, showJobsFilters, t, diff --git a/src/components/ui/native-search-field.tsx b/src/components/ui/native-search-field.tsx index 48dd735..bfd2e21 100644 --- a/src/components/ui/native-search-field.tsx +++ b/src/components/ui/native-search-field.tsx @@ -4,9 +4,9 @@ import { type StyleProp, TextInput, type TextInputProps, + View, type ViewStyle, } from "react-native"; -import Animated, { LinearTransition, ReduceMotion } from "react-native-reanimated"; import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; import { useBrand } from "@/hooks/use-brand"; import { useThemePreference } from "@/hooks/use-theme-preference"; @@ -56,8 +56,7 @@ export function NativeSearchField({ const metrics = size === "sm" ? SEARCH_SIZE_SM : SEARCH_SIZE_MD; return ( - ) : null} - + ); } diff --git a/src/tw/image.tsx b/src/tw/image.tsx index 1b4fb58..5e1b154 100644 --- a/src/tw/image.tsx +++ b/src/tw/image.tsx @@ -9,12 +9,22 @@ const AnimatedExpoImage = Animated.createAnimatedComponent(RNImage); // eslint-disable-next-line @typescript-eslint/no-explicit-any function CSSImage(props: any) { const { objectFit, objectPosition, ...style } = StyleSheet.flatten(props.style) || {}; + const normalizedSource = typeof props.source === "string" ? { uri: props.source } : props.source; + const derivedRecyclingKey = + props.recyclingKey ?? + (normalizedSource && + typeof normalizedSource === "object" && + !Array.isArray(normalizedSource) && + "uri" in normalizedSource + ? normalizedSource.uri + : undefined); return ( ); From c17ceccfc2921b4022bbdf6011463f5585ee802a Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 23 Mar 2026 18:47:56 +0200 Subject: [PATCH 06/44] Use debugOptimized for Android dev-client installs --- scripts/android/start-expo-linux.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/android/start-expo-linux.sh b/scripts/android/start-expo-linux.sh index b09e39b..b25b1ed 100755 --- a/scripts/android/start-expo-linux.sh +++ b/scripts/android/start-expo-linux.sh @@ -105,10 +105,11 @@ done APP_ID="$(node -e "const fs=require('fs');const p='app.json';let id='com.derpcat.queue';try{const j=JSON.parse(fs.readFileSync(p,'utf8'));id=(j.expo&&j.expo.android&&j.expo.android.package)||id;}catch{};process.stdout.write(id)")" METRO_PORT="${EXPO_METRO_PORT:-8081}" +ANDROID_BUILD_VARIANT="${EXPO_ANDROID_BUILD_VARIANT:-debugOptimized}" if ! adb -s "$SERIAL" shell pm list packages "$APP_ID" | tr -d '\r' | grep -q "package:$APP_ID"; then - echo "Dev client not installed ($APP_ID). Building/installing now..." - npx expo run:android + echo "Dev client not installed ($APP_ID). Building/installing variant '$ANDROID_BUILD_VARIANT'..." + npx expo run:android --variant "$ANDROID_BUILD_VARIANT" fi echo "Launching Expo dev client on $SERIAL" From bb5429a774054fc7830a3c801576d7e88fb71020 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 23 Mar 2026 20:30:28 +0200 Subject: [PATCH 07/44] Optimize map tab startup and polish map UI --- docs/maplibre-style-workflow.md | 34 +++++++++++++++ .../(instructor-tabs)/instructor/_layout.tsx | 43 ++++++++++++++++++- src/components/layout/tab-overlay-anchor.tsx | 3 +- src/components/layout/top-sheet.tsx | 8 ++-- src/components/map-tab/map-tab/index.tsx | 5 +-- .../map-tab/map-tab/map-mobile-stage.tsx | 10 +---- .../map-tab/map-tab/map-sheet-header.tsx | 6 ++- .../map-tab/use-map-tab-controller.tsx | 4 ++ .../map-tab/map/map-selected-zones-strip.tsx | 16 ++++++- .../map-tab/map/map-sheet-results.tsx | 35 +++++++++------ .../maps/queue-map-zone-polygons.tsx | 6 ++- src/components/maps/queue-map.native.tsx | 17 ++++++++ src/modules/navigation/role-tabs-layout.tsx | 8 ++-- 13 files changed, 154 insertions(+), 41 deletions(-) create mode 100644 docs/maplibre-style-workflow.md diff --git a/docs/maplibre-style-workflow.md b/docs/maplibre-style-workflow.md new file mode 100644 index 0000000..56a28bd --- /dev/null +++ b/docs/maplibre-style-workflow.md @@ -0,0 +1,34 @@ +# MapLibre Style Workflow + +This app already supports remote vector style URLs through: + +- `EXPO_PUBLIC_MAP_VECTOR_STYLE_LIGHT_URL` +- `EXPO_PUBLIC_MAP_VECTOR_STYLE_DARK_URL` +- `EXPO_PUBLIC_BASEMAP_STYLE_URL` +- `EXPO_PUBLIC_MAP_VECTOR_STYLE_URL` + +The current defaults live in [src/components/maps/queue-map-apple-theme.ts](/home/derpcat/projects/queue-nativewind-styling/src/components/maps/queue-map-apple-theme.ts). + +## Recommended workflow + +1. Open the target style in Maputnik. +2. Adjust land, water, roads, labels, and density there instead of hardcoding visual tweaks in app code. +3. Publish the edited style JSON to a stable URL. +4. Point the app env vars at that hosted style. +5. Keep app-side map styling focused on interaction overlays only: + - selected zone outline + - selected zone fill + - focused pin + - editing affordances + +## Suggested style direction + +- Reduce non-essential label density. +- Keep water and land contrast gentle. +- Avoid saturated road colors. +- Preserve strong contrast for selected zones only. +- Keep base map neutral so the zone overlay reads first. + +## Why this is the right split + +Maputnik is a style editor, not a runtime dependency. Using it offline/in tooling keeps the app lighter and makes style changes reproducible without shipping editor code in production. diff --git a/src/app/(app)/(instructor-tabs)/instructor/_layout.tsx b/src/app/(app)/(instructor-tabs)/instructor/_layout.tsx index 1e84958..2159938 100644 --- a/src/app/(app)/(instructor-tabs)/instructor/_layout.tsx +++ b/src/app/(app)/(instructor-tabs)/instructor/_layout.tsx @@ -1,6 +1,9 @@ import { useQuery } from "convex/react"; -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; +import { Platform } from "react-native"; +import { warmMapStyleSpec } from "@/components/maps/queue-map.native.helpers"; +import { APPLE_MAP_THEME } from "@/components/maps/queue-map-apple-theme"; import { api } from "@/convex/_generated/api"; import { RoleTabsLayout } from "@/modules/navigation/role-tabs-layout"; import { ROLE_TAB_ROUTE_NAMES } from "@/navigation/role-routes"; @@ -12,6 +15,7 @@ export default function InstructorTabsLayout() { const instructorTabCounts = useQuery(api.jobs.getInstructorTabCounts, tabCountsArgs); const unreadNotificationCount = useQuery(api.inbox.getMyUnreadNotificationCount, emptyArgs); + useQuery(api.instructorZones.getMyInstructorZones, emptyArgs); const jobsBadgeCount = instructorTabCounts?.jobsBadgeCount ?? 0; const calendarBadgeCount = instructorTabCounts?.calendarBadgeCount ?? 0; @@ -25,5 +29,42 @@ export default function InstructorTabsLayout() { [calendarBadgeCount, jobsBadgeCount, profileBadgeCount], ); + useEffect(() => { + if (Platform.OS === "web") { + return; + } + + let cancelled = false; + let idleId: number | null = null; + let timeoutId: ReturnType | null = null; + const schedulePrewarm = () => { + if (cancelled) { + return; + } + + warmMapStyleSpec(APPLE_MAP_THEME.mapStyleLightUrl); + warmMapStyleSpec(APPLE_MAP_THEME.mapStyleDarkUrl); + + void import("@/components/maps/queue-map.native"); + void import("@/constants/zones-map"); + }; + + if (typeof globalThis.requestIdleCallback === "function") { + idleId = globalThis.requestIdleCallback(schedulePrewarm, { timeout: 600 }); + } else { + timeoutId = setTimeout(schedulePrewarm, 120); + } + + return () => { + cancelled = true; + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + if (idleId !== null && typeof globalThis.cancelIdleCallback === "function") { + globalThis.cancelIdleCallback(idleId); + } + }; + }, []); + return ; } diff --git a/src/components/layout/tab-overlay-anchor.tsx b/src/components/layout/tab-overlay-anchor.tsx index e893190..f5a8707 100644 --- a/src/components/layout/tab-overlay-anchor.tsx +++ b/src/components/layout/tab-overlay-anchor.tsx @@ -1,5 +1,6 @@ import type { PropsWithChildren } from "react"; import { type StyleProp, View, type ViewStyle } from "react-native"; +import { BrandSpacing } from "@/constants/brand"; import { useAppInsets } from "@/hooks/use-app-insets"; export type TabOverlayAnchorProps = PropsWithChildren<{ @@ -11,7 +12,7 @@ export type TabOverlayAnchorProps = PropsWithChildren<{ export function TabOverlayAnchor({ children, side = "right", - offset = 16, + offset = BrandSpacing.lg, style, }: TabOverlayAnchorProps) { const { overlayBottom } = useAppInsets(); diff --git a/src/components/layout/top-sheet.tsx b/src/components/layout/top-sheet.tsx index 8aa73fd..5211bb2 100644 --- a/src/components/layout/top-sheet.tsx +++ b/src/components/layout/top-sheet.tsx @@ -3,7 +3,6 @@ import type { ColorValue, StyleProp, ViewStyle } from "react-native"; import { useWindowDimensions, View } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import Animated, { - interpolate, runOnJS, useAnimatedStyle, useSharedValue, @@ -81,7 +80,6 @@ function DragHandle({ borderColor }: { borderColor: ColorValue }) { height: HANDLE_PILL_HEIGHT, borderRadius: BrandRadius.pill, backgroundColor: borderColor, - opacity: 0.5, }} /> @@ -266,12 +264,12 @@ export function TopSheet({ backgroundColor: animatedBackground.value, })); const revealStyle = useAnimatedStyle(() => ({ - flex: 1, + flex: expandedProgress.value, minHeight: 0, - opacity: expandedProgress.value, + overflow: "hidden", transform: [ { - translateY: interpolate(expandedProgress.value, [0, 1], [8, 0]), + translateY: (1 - expandedProgress.value) * 8, }, ], })); diff --git a/src/components/map-tab/map-tab/index.tsx b/src/components/map-tab/map-tab/index.tsx index e1b2b83..d02f129 100644 --- a/src/components/map-tab/map-tab/index.tsx +++ b/src/components/map-tab/map-tab/index.tsx @@ -24,11 +24,9 @@ export default function MapTabScreen() { mapPalette, mapPin, noopMapPress, - overlayBottom, palette, pendingChangeCount, persistedZoneIds, - remoteZones, saveError, selectedZoneIds, selectedZones, @@ -60,7 +58,7 @@ export default function MapTabScreen() { return ; } - if (!isMapBodyReady || remoteZones === undefined) { + if (!isMapBodyReady) { return ( - + diff --git a/src/components/map-tab/map-tab/map-sheet-header.tsx b/src/components/map-tab/map-tab/map-sheet-header.tsx index e8482cf..71900f1 100644 --- a/src/components/map-tab/map-tab/map-sheet-header.tsx +++ b/src/components/map-tab/map-tab/map-sheet-header.tsx @@ -3,7 +3,7 @@ import { View } from "react-native"; import { MapSelectedZonesStrip } from "@/components/map-tab/map/map-selected-zones-strip"; import { NativeSearchField } from "@/components/ui/native-search-field"; -import { type BrandPalette, BrandSpacing } from "@/constants/brand"; +import { type BrandPalette, BrandSpacing, type getMapBrandPalette } from "@/constants/brand"; import type { ZoneOption } from "@/constants/zones"; type MapSheetHeaderProps = { @@ -11,6 +11,7 @@ type MapSheetHeaderProps = { onChangeSearch: (text: string) => void; onFocusSearch: () => void; palette: BrandPalette; + mapPalette: ReturnType; selectedZones: ZoneOption[]; onPressZone: (zoneId: string | null) => void; t: TFunction; @@ -23,6 +24,7 @@ export function MapSheetHeader({ onChangeSearch, onFocusSearch, palette, + mapPalette, selectedZones, onPressZone, t, @@ -38,12 +40,14 @@ export function MapSheetHeader({ placeholder={t("mapTab.searchPlaceholder")} clearAccessibilityLabel={t("common.clear")} size="sm" + containerStyle={{ backgroundColor: mapPalette.surfaceAlt as string }} /> diff --git a/src/components/map-tab/map-tab/use-map-tab-controller.tsx b/src/components/map-tab/map-tab/use-map-tab-controller.tsx index 36b502d..6da0249 100644 --- a/src/components/map-tab/map-tab/use-map-tab-controller.tsx +++ b/src/components/map-tab/map-tab/use-map-tab-controller.tsx @@ -347,6 +347,7 @@ export function useMapTabController() { zoneLanguage={zoneLanguage} zoneModeActive={zoneModeActive} palette={palette} + mapPalette={mapPalette} onPressZone={handleZoneResultPress} onPressCity={handleCityResultPress} onToggleCityExpanded={toggleCityExpanded} @@ -356,6 +357,7 @@ export function useMapTabController() { isSheetExpanded, handleCityResultPress, handleZoneResultPress, + mapPalette, palette, saveError, toggleCityExpanded, @@ -374,6 +376,7 @@ export function useMapTabController() { onChangeSearch={handleMapSheetSearchChange} onFocusSearch={openSearchSheet} palette={palette} + mapPalette={mapPalette} selectedZones={selectedZones} onPressZone={setFocusZoneId} t={t} @@ -405,6 +408,7 @@ export function useMapTabController() { handleMapSheetSearchChange, handleSheetStepChange, mapExpandedResults, + mapPalette, openSearchSheet, palette, selectedZones, diff --git a/src/components/map-tab/map/map-selected-zones-strip.tsx b/src/components/map-tab/map/map-selected-zones-strip.tsx index 697ddd0..a42f43f 100644 --- a/src/components/map-tab/map/map-selected-zones-strip.tsx +++ b/src/components/map-tab/map/map-selected-zones-strip.tsx @@ -1,7 +1,13 @@ import { useTranslation } from "react-i18next"; import { ScrollView, Text, View } from "react-native"; import { ChoicePill } from "@/components/ui/choice-pill"; -import { type BrandPalette, BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; +import { + type BrandPalette, + BrandRadius, + BrandSpacing, + BrandType, + type getMapBrandPalette, +} from "@/constants/brand"; import type { ZoneOption } from "@/constants/zones"; const COMPACT_ZONE_PILL_MIN_HEIGHT = BrandSpacing.controlSm - BrandSpacing.xs; @@ -12,6 +18,7 @@ type MapSelectedZonesStripProps = { focusZoneId: string | null; zoneLanguage: "en" | "he"; palette: BrandPalette; + mapPalette: ReturnType; onPressZone: (zoneId: string) => void; }; @@ -20,6 +27,7 @@ export function MapSelectedZonesStrip({ focusZoneId, zoneLanguage, palette, + mapPalette, onPressZone, }: MapSelectedZonesStripProps) { const { t } = useTranslation(); @@ -47,6 +55,10 @@ export function MapSelectedZonesStrip({ compact fullWidth={false} onPress={() => onPressZone(zone.id)} + backgroundColor={mapPalette.surfaceAlt} + selectedBackgroundColor={palette.surfaceElevated} + labelColor={palette.text} + selectedLabelColor={palette.primary} style={{ minHeight: COMPACT_ZONE_PILL_MIN_HEIGHT, paddingHorizontal: BrandSpacing.md, @@ -63,7 +75,7 @@ export function MapSelectedZonesStrip({ borderCurve: "continuous", paddingHorizontal: BrandSpacing.md, paddingVertical: BrandSpacing.xs, - backgroundColor: palette.surfaceAlt as string, + backgroundColor: mapPalette.surfaceAlt as string, justifyContent: "center", }} > diff --git a/src/components/map-tab/map/map-sheet-results.tsx b/src/components/map-tab/map/map-sheet-results.tsx index 53f674e..1ec688c 100644 --- a/src/components/map-tab/map/map-sheet-results.tsx +++ b/src/components/map-tab/map/map-sheet-results.tsx @@ -3,7 +3,13 @@ import { FlatList, Pressable, Text, View } from "react-native"; import type { ZoneCityListItem } from "@/components/map-tab/zone-city-tree"; import { ThemedText } from "@/components/themed-text"; import { IconSymbol } from "@/components/ui/icon-symbol"; -import { type BrandPalette, BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; +import { + type BrandPalette, + BrandRadius, + BrandSpacing, + BrandType, + type getMapBrandPalette, +} from "@/constants/brand"; import { useIsRtl } from "@/hooks/use-is-rtl"; const MAP_RESULT_INDENT = BrandSpacing.xl + BrandSpacing.lg; @@ -16,6 +22,7 @@ type MapSheetResultsProps = { zoneLanguage: "en" | "he"; zoneModeActive: boolean; palette: BrandPalette; + mapPalette: ReturnType; onPressZone: (zoneId: string) => void; onPressCity: (cityKey: string) => void; onToggleCityExpanded: (cityKey: string) => void; @@ -28,6 +35,7 @@ export function MapSheetResults({ zoneLanguage, zoneModeActive, palette, + mapPalette, onPressZone, onPressCity, onToggleCityExpanded, @@ -66,9 +74,9 @@ export function MapSheetResults({ gap: BrandSpacing.xs, }} keyboardShouldPersistTaps="handled" - initialNumToRender={20} - maxToRenderPerBatch={28} - windowSize={9} + initialNumToRender={12} + maxToRenderPerBatch={18} + windowSize={5} ItemSeparatorComponent={() => } ListEmptyComponent={ @@ -100,8 +108,8 @@ export function MapSheetResults({ borderRadius: MAP_RESULT_RADIUS, borderCurve: "continuous", backgroundColor: item.selected - ? (palette.primarySubtle as string) - : (palette.surfaceAlt as string), + ? (palette.surfaceElevated as string) + : (mapPalette.surfaceAlt as string), }} > @@ -210,7 +218,6 @@ export function MapSheetResults({ : zoneModeActive && isFullySelected ? (palette.primary as string) : (palette.textMuted as string), - opacity: 0.92, }} > {summary} @@ -238,7 +245,7 @@ export function MapSheetResults({ style={({ pressed }) => ({ paddingHorizontal: BrandSpacing.controlX, paddingVertical: BrandSpacing.sm, - opacity: pressed ? 0.82 : 1, + backgroundColor: pressed ? (palette.surface as string) : undefined, })} > (null); const [baseMapStyle, setBaseMapStyle] = useState(null); + const [showLabelLayers, setShowLabelLayers] = useState(false); const preferredStyleUrl = resolvedScheme === "dark" ? APPLE_MAP_THEME.mapStyleDarkUrl : APPLE_MAP_THEME.mapStyleLightUrl; const styleFetchUrl = @@ -138,6 +139,21 @@ export const QueueMap = memo(function QueueMap({ }; }, [mapLoadState]); + useEffect(() => { + if (mapLoadState !== "ready") { + setShowLabelLayers(false); + return; + } + + const timeout = setTimeout(() => { + setShowLabelLayers(true); + }, 180); + + return () => { + clearTimeout(timeout); + }; + }, [mapLoadState]); + useEffect(() => { if (mapLoadState !== "ready") return; @@ -295,6 +311,7 @@ export const QueueMap = memo(function QueueMap({ name.replaceAll("_", "-"); return ( @@ -59,7 +61,7 @@ export function RoleTabsLayout({ appRole, badgeCountByRoute }: RoleTabsLayoutPro badgeBackgroundColor={palette.primary as string} badgeTextColor={palette.onPrimary as string} indicatorColor={palette.primarySubtle as string} - shadowColor="transparent" + shadowColor={palette.surface as string} labelVisibilityMode="unlabeled" disableTransparentOnScrollEdge > @@ -83,8 +85,8 @@ export function RoleTabsLayout({ appRole, badgeCountByRoute }: RoleTabsLayoutPro ), selected: ( ), }} From cbdbf3b5802a67fdee0a5f0e40464a7a5b3892cb Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 23 Mar 2026 20:32:06 +0200 Subject: [PATCH 08/44] Reduce map tab JS cold-start work --- .../(instructor-tabs)/instructor/_layout.tsx | 2 + .../map-tab/use-map-tab-controller.tsx | 50 +++++++++---------- 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/src/app/(app)/(instructor-tabs)/instructor/_layout.tsx b/src/app/(app)/(instructor-tabs)/instructor/_layout.tsx index 2159938..b02d199 100644 --- a/src/app/(app)/(instructor-tabs)/instructor/_layout.tsx +++ b/src/app/(app)/(instructor-tabs)/instructor/_layout.tsx @@ -47,6 +47,8 @@ export default function InstructorTabsLayout() { void import("@/components/maps/queue-map.native"); void import("@/constants/zones-map"); + void import("@/components/map-tab/map-tab/use-map-tab-controller"); + void import("@/components/map-tab/map-tab/map-sheet-header"); }; if (typeof globalThis.requestIdleCallback === "function") { diff --git a/src/components/map-tab/map-tab/use-map-tab-controller.tsx b/src/components/map-tab/map-tab/use-map-tab-controller.tsx index 6da0249..323c6f0 100644 --- a/src/components/map-tab/map-tab/use-map-tab-controller.tsx +++ b/src/components/map-tab/map-tab/use-map-tab-controller.tsx @@ -28,6 +28,18 @@ import { const MAX_ZONES = 25; const MAP_CAMERA_TOP_OFFSET = BrandSpacing.xl; const MAP_CAMERA_BOTTOM_OFFSET = BrandSpacing.xl; +const STATIC_ZONE_CITY_GROUPS = buildZoneCityGroups(ZONE_OPTIONS); +const STATIC_ZONE_BY_ID = new Map(ZONE_OPTIONS.map((zone) => [zone.id, zone])); +const STATIC_ZONE_CITY_BY_ZONE_ID = new Map(); +const STATIC_ZONE_CITY_GROUP_BY_KEY = new Map( + STATIC_ZONE_CITY_GROUPS.map((group) => [group.cityKey, group]), +); + +for (const group of STATIC_ZONE_CITY_GROUPS) { + for (const zone of group.zones) { + STATIC_ZONE_CITY_BY_ZONE_ID.set(zone.id, group.cityKey); + } +} export function useMapTabController() { const { t, i18n } = useTranslation(); @@ -135,22 +147,8 @@ export function useMapTabController() { ); const deferredSelectedZoneSet = useMemo(() => new Set(selectedZoneIds), [selectedZoneIds]); const expandedCityKeySet = useMemo(() => new Set(expandedCityKeys), [expandedCityKeys]); - const zoneCityGroups = useMemo(() => buildZoneCityGroups(ZONE_OPTIONS), []); - const zoneCityByZoneId = useMemo(() => { - const entries = new Map(); - for (const group of zoneCityGroups) { - for (const zone of group.zones) { - entries.set(zone.id, group.cityKey); - } - } - return entries; - }, [zoneCityGroups]); - const zoneCityGroupByKey = useMemo( - () => new Map(zoneCityGroups.map((group) => [group.cityKey, group])), - [zoneCityGroups], - ); const filteredZones = useMemo( - () => buildFilteredZones(ZONE_OPTIONS, zoneSearch, zoneLanguage), + () => (Platform.OS === "web" ? buildFilteredZones(ZONE_OPTIONS, zoneSearch, zoneLanguage) : []), [zoneLanguage, zoneSearch], ); const shouldBuildZoneCityItems = @@ -159,7 +157,7 @@ export function useMapTabController() { () => shouldBuildZoneCityItems ? buildZoneCityListItems({ - groups: zoneCityGroups, + groups: STATIC_ZONE_CITY_GROUPS, language: zoneLanguage, query: zoneSearch, expandedCityKeys: expandedCityKeySet, @@ -170,17 +168,19 @@ export function useMapTabController() { deferredSelectedZoneSet, expandedCityKeySet, shouldBuildZoneCityItems, - zoneCityGroups, zoneLanguage, zoneSearch, ], ); const selectedZones = useMemo( - () => ZONE_OPTIONS.filter((zone) => deferredSelectedZoneSet.has(zone.id)), - [deferredSelectedZoneSet], + () => + selectedZoneIds + .map((zoneId) => STATIC_ZONE_BY_ID.get(zoneId)) + .filter((zone): zone is NonNullable => Boolean(zone)), + [selectedZoneIds], ); const focusedZone = useMemo( - () => ZONE_OPTIONS.find((zone) => zone.id === focusZoneId) ?? null, + () => (focusZoneId ? (STATIC_ZONE_BY_ID.get(focusZoneId) ?? null) : null), [focusZoneId], ); const focusedZoneLabel = focusedZone?.label[zoneLanguage] ?? null; @@ -196,20 +196,20 @@ export function useMapTabController() { setExpandedCityKeys((current) => { const next = new Set(current); for (const zoneId of selectedZoneIds) { - const cityKey = zoneCityByZoneId.get(zoneId); + const cityKey = STATIC_ZONE_CITY_BY_ZONE_ID.get(zoneId); if (cityKey) { next.add(cityKey); } } if (focusZoneId) { - const cityKey = zoneCityByZoneId.get(focusZoneId); + const cityKey = STATIC_ZONE_CITY_BY_ZONE_ID.get(focusZoneId); if (cityKey) { next.add(cityKey); } } return next.size === current.length ? current : [...next]; }); - }, [focusZoneId, selectedZoneIds, zoneCityByZoneId, zoneModeActive]); + }, [focusZoneId, selectedZoneIds, zoneModeActive]); const toggleCityExpanded = useCallback((cityKey: string) => { setExpandedCityKeys((current) => @@ -221,7 +221,7 @@ export function useMapTabController() { const toggleCity = useCallback( (cityKey: string) => { - const group = zoneCityGroupByKey.get(cityKey); + const group = STATIC_ZONE_CITY_GROUP_BY_KEY.get(cityKey); if (!group) return; if (Platform.OS === "ios") { void Haptics.selectionAsync(); @@ -240,7 +240,7 @@ export function useMapTabController() { ); } }, - [applySelectedZoneIds, selectedZoneIds, zoneCityGroupByKey], + [applySelectedZoneIds, selectedZoneIds], ); const openZoneEditor = useCallback(() => { From d622f5b9867f8baf2556a3aedad8521e3c62d466 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 23 Mar 2026 20:39:17 +0200 Subject: [PATCH 09/44] Stabilize profile sheet registration and Android tab icons --- .../instructor/profile/index.tsx | 6 ++- .../(studio-tabs)/studio/profile/index.tsx | 6 ++- src/modules/navigation/role-tabs-layout.tsx | 37 ++++++++++--------- 3 files changed, 30 insertions(+), 19 deletions(-) diff --git a/src/app/(app)/(instructor-tabs)/instructor/profile/index.tsx b/src/app/(app)/(instructor-tabs)/instructor/profile/index.tsx index e35fca6..ed01c44 100644 --- a/src/app/(app)/(instructor-tabs)/instructor/profile/index.tsx +++ b/src/app/(app)/(instructor-tabs)/instructor/profile/index.tsx @@ -304,7 +304,11 @@ export default function InstructorProfileScreen() { const isProfileIndexRoute = pathname === INSTRUCTOR_PROFILE_ROUTE || pathname.endsWith("/profile"); - useGlobalTopSheet("profile", !isDesktopWeb && isProfileIndexRoute ? profileSheetConfig : null); + useGlobalTopSheet( + "profile", + !isDesktopWeb && isProfileIndexRoute ? profileSheetConfig : null, + "profile:index:instructor", + ); if ( !hasActivated || diff --git a/src/app/(app)/(studio-tabs)/studio/profile/index.tsx b/src/app/(app)/(studio-tabs)/studio/profile/index.tsx index 2f4e7cd..4b7e858 100644 --- a/src/app/(app)/(studio-tabs)/studio/profile/index.tsx +++ b/src/app/(app)/(studio-tabs)/studio/profile/index.tsx @@ -321,7 +321,11 @@ export default function StudioProfileScreen() { const isProfileIndexRoute = pathname === STUDIO_PROFILE_ROUTE || pathname.endsWith("/profile"); - useGlobalTopSheet("profile", !isDesktopWeb && isProfileIndexRoute ? profileSheetConfig : null); + useGlobalTopSheet( + "profile", + !isDesktopWeb && isProfileIndexRoute ? profileSheetConfig : null, + "profile:index:studio", + ); if ( !hasActivated || diff --git a/src/modules/navigation/role-tabs-layout.tsx b/src/modules/navigation/role-tabs-layout.tsx index 87113d3..955bb81 100644 --- a/src/modules/navigation/role-tabs-layout.tsx +++ b/src/modules/navigation/role-tabs-layout.tsx @@ -1,7 +1,6 @@ import MaterialCommunityIcons from "@expo/vector-icons/MaterialCommunityIcons"; -import MaterialIcons from "@expo/vector-icons/MaterialIcons"; import { NativeTabs } from "expo-router/unstable-native-tabs"; -import { View } from "react-native"; +import { Platform, View } from "react-native"; import { GlobalTopSheet } from "@/components/layout/global-top-sheet"; import { ScrollSheetProvider } from "@/components/layout/scroll-sheet-provider"; @@ -31,7 +30,6 @@ export function RoleTabsLayout({ appRole, badgeCountByRoute }: RoleTabsLayoutPro const tabBarBackgroundColor = palette.surfaceElevated as string; const defaultIconColor = palette.textMicro as string; const selectedIconColor = palette.primary as string; - const getSelectedMaterialIconName = (name: string) => name.replaceAll("_", "-"); return ( @@ -72,24 +70,29 @@ export function RoleTabsLayout({ appRole, badgeCountByRoute }: RoleTabsLayoutPro contentStyle={{ backgroundColor: sceneBackgroundColor }} > - ), - selected: ( - - ), - }} + src={ + Platform.OS === "android" + ? undefined + : { + default: ( + + ), + selected: ( + + ), + } + } /> From 7c2719a8f68fa79958fa13218c23baff1aac5920 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 23 Mar 2026 20:42:37 +0200 Subject: [PATCH 10/44] Fix profile sheet transition path and map sheet spacing --- .../instructor/profile/index.tsx | 4 ++- .../(studio-tabs)/studio/profile/index.tsx | 4 ++- src/components/layout/global-top-sheet.tsx | 8 +++-- src/modules/navigation/role-tabs-layout.tsx | 34 ++++++++----------- 4 files changed, 26 insertions(+), 24 deletions(-) diff --git a/src/app/(app)/(instructor-tabs)/instructor/profile/index.tsx b/src/app/(app)/(instructor-tabs)/instructor/profile/index.tsx index ed01c44..dbb493c 100644 --- a/src/app/(app)/(instructor-tabs)/instructor/profile/index.tsx +++ b/src/app/(app)/(instructor-tabs)/instructor/profile/index.tsx @@ -288,7 +288,9 @@ export default function InstructorProfileScreen() { const profileSheetConfig = useMemo( () => ({ - content: profileSheetContent, + render: () => ({ + children: profileSheetContent, + }), steps: [profileSheetStep], initialStep: 0, padding: { diff --git a/src/app/(app)/(studio-tabs)/studio/profile/index.tsx b/src/app/(app)/(studio-tabs)/studio/profile/index.tsx index 4b7e858..962da37 100644 --- a/src/app/(app)/(studio-tabs)/studio/profile/index.tsx +++ b/src/app/(app)/(studio-tabs)/studio/profile/index.tsx @@ -306,7 +306,9 @@ export default function StudioProfileScreen() { const profileSheetConfig = useMemo( () => ({ - content: profileSheetContent, + render: () => ({ + children: profileSheetContent, + }), steps: [profileSheetStep], initialStep: 0, padding: { diff --git a/src/components/layout/global-top-sheet.tsx b/src/components/layout/global-top-sheet.tsx index c1e5e83..6ac4a7b 100644 --- a/src/components/layout/global-top-sheet.tsx +++ b/src/components/layout/global-top-sheet.tsx @@ -241,9 +241,11 @@ export function GlobalTopSheet() { stickyFooter={renderTransitionedNode("sticky-footer", richStickyFooter)} revealOnExpand={renderTransitionedNode("reveal", richRevealOnExpand, { flex: 1 })} > - {renderTransitionedNode("children", richChildren ?? , { - flex: 1, - })} + {renderTransitionedNode( + "children", + richChildren, + richChildren ? { flex: 1 } : undefined, + )} {renderTransitionedNode("overlay", activeConfig.overlay, styles.overlayLayer)} diff --git a/src/modules/navigation/role-tabs-layout.tsx b/src/modules/navigation/role-tabs-layout.tsx index 955bb81..15b34b8 100644 --- a/src/modules/navigation/role-tabs-layout.tsx +++ b/src/modules/navigation/role-tabs-layout.tsx @@ -70,29 +70,25 @@ export function RoleTabsLayout({ appRole, badgeCountByRoute }: RoleTabsLayoutPro contentStyle={{ backgroundColor: sceneBackgroundColor }} > - ), - selected: ( - - ), - } - } + src={{ + default: ( + + ), + selected: ( + + ), + }} /> From 5c5c0b4ced4fb8c6573b4c637d391dd8e1f0f635 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 23 Mar 2026 20:42:58 +0200 Subject: [PATCH 11/44] Use native Android tab icons --- src/modules/navigation/role-tabs-layout.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/navigation/role-tabs-layout.tsx b/src/modules/navigation/role-tabs-layout.tsx index 15b34b8..280c89b 100644 --- a/src/modules/navigation/role-tabs-layout.tsx +++ b/src/modules/navigation/role-tabs-layout.tsx @@ -1,6 +1,6 @@ import MaterialCommunityIcons from "@expo/vector-icons/MaterialCommunityIcons"; import { NativeTabs } from "expo-router/unstable-native-tabs"; -import { Platform, View } from "react-native"; +import { View } from "react-native"; import { GlobalTopSheet } from "@/components/layout/global-top-sheet"; import { ScrollSheetProvider } from "@/components/layout/scroll-sheet-provider"; @@ -70,7 +70,7 @@ export function RoleTabsLayout({ appRole, badgeCountByRoute }: RoleTabsLayoutPro contentStyle={{ backgroundColor: sceneBackgroundColor }} > Date: Mon, 23 Mar 2026 20:46:20 +0200 Subject: [PATCH 12/44] Refine MapLibre basemap styling --- docs/maplibre-style-workflow.md | 5 ++++ src/components/maps/queue-map-apple-theme.ts | 2 +- .../maps/queue-map.native.helpers.ts | 24 ++++++++++++++++++- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/docs/maplibre-style-workflow.md b/docs/maplibre-style-workflow.md index 56a28bd..997dffe 100644 --- a/docs/maplibre-style-workflow.md +++ b/docs/maplibre-style-workflow.md @@ -9,6 +9,11 @@ This app already supports remote vector style URLs through: The current defaults live in [src/components/maps/queue-map-apple-theme.ts](/home/derpcat/projects/queue-nativewind-styling/src/components/maps/queue-map-apple-theme.ts). +Current default direction: + +- light: OpenFreeMap `liberty` +- dark: OpenFreeMap `dark` + ## Recommended workflow 1. Open the target style in Maputnik. diff --git a/src/components/maps/queue-map-apple-theme.ts b/src/components/maps/queue-map-apple-theme.ts index 2878209..9ec8ed4 100644 --- a/src/components/maps/queue-map-apple-theme.ts +++ b/src/components/maps/queue-map-apple-theme.ts @@ -1,4 +1,4 @@ -const DEFAULT_MAP_STYLE_LIGHT_URL = "https://tiles.openfreemap.org/styles/positron"; +const DEFAULT_MAP_STYLE_LIGHT_URL = "https://tiles.openfreemap.org/styles/liberty"; const DEFAULT_MAP_STYLE_DARK_URL = "https://tiles.openfreemap.org/styles/dark"; function parseNumber(value: string | undefined, fallback: number) { diff --git a/src/components/maps/queue-map.native.helpers.ts b/src/components/maps/queue-map.native.helpers.ts index 25bed7e..0eece3a 100644 --- a/src/components/maps/queue-map.native.helpers.ts +++ b/src/components/maps/queue-map.native.helpers.ts @@ -51,6 +51,26 @@ function isRoadNumberLayer(layer: AnyStyleLayer) { return false; } +function isLowValueSymbolLayer(layer: AnyStyleLayer) { + const id = String(layer?.id ?? "").toLowerCase(); + const sourceLayer = String(layer?.["source-layer"] ?? "").toLowerCase(); + if (String(layer?.type ?? "") !== "symbol") { + return false; + } + + const value = `${id} ${sourceLayer}`; + return ( + value.includes("poi") || + value.includes("transit") || + value.includes("rail") || + value.includes("bus") || + value.includes("parking") || + value.includes("ferry") || + value.includes("airport") || + value.includes("aerialway") + ); +} + export function withMapPersonality( style: AnyStyleSpec, palette: ReturnType, @@ -59,6 +79,7 @@ export function withMapPersonality( const layers = (style.layers ?? []) .filter((layer) => !isRoadNumberLayer(layer)) .filter((layer) => (showBaseLabels ? true : String(layer?.type ?? "") !== "symbol")) + .filter((layer) => !isLowValueSymbolLayer(layer)) .map((layer) => { const nextLayer = { ...layer }; const id = String(nextLayer.id ?? "").toLowerCase(); @@ -92,6 +113,7 @@ export function withMapPersonality( } if (sourceLayer.includes("road") && layerType === "line") { paint["line-color"] = palette.roadLine; + paint["line-opacity"] = 0.92; } if ((sourceLayer.includes("building") || id.includes("building")) && layerType === "fill") { paint["fill-color"] = palette.buildingFill; @@ -99,7 +121,7 @@ export function withMapPersonality( if (layerType === "symbol") { paint["text-color"] = palette.text; paint["text-halo-color"] = palette.textHalo; - paint["text-halo-width"] = 1; + paint["text-halo-width"] = 0.8; } nextLayer.paint = paint; From 8ee60eece8951a01d3d25383c65bbe65d027d23b Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 23 Mar 2026 20:48:14 +0200 Subject: [PATCH 13/44] Tighten map overlay spacing and collapsed sheet --- src/components/map-tab/map-tab/map-mobile-stage.tsx | 5 ++++- src/components/map-tab/map-tab/use-map-tab-controller.tsx | 2 +- src/hooks/use-app-insets.ts | 3 ++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/components/map-tab/map-tab/map-mobile-stage.tsx b/src/components/map-tab/map-tab/map-mobile-stage.tsx index c8b6c37..b649724 100644 --- a/src/components/map-tab/map-tab/map-mobile-stage.tsx +++ b/src/components/map-tab/map-tab/map-mobile-stage.tsx @@ -72,9 +72,12 @@ export function MapMobileStage({ tone={zoneModeActive ? "primary" : "primarySubtle"} size={58} disabled={isSaving} + backgroundColorOverride={ + zoneModeActive ? (palette.primary as string) : (palette.surface as string) + } icon={ diff --git a/src/components/map-tab/map-tab/use-map-tab-controller.tsx b/src/components/map-tab/map-tab/use-map-tab-controller.tsx index 323c6f0..76b9c0f 100644 --- a/src/components/map-tab/map-tab/use-map-tab-controller.tsx +++ b/src/components/map-tab/map-tab/use-map-tab-controller.tsx @@ -396,7 +396,7 @@ export function useMapTabController() { }), draggable: true, expandable: true, - steps: [0.24, 0.56, 0.94], + steps: [0.19, 0.56, 0.94], initialStep: 0, activeStep: sheetStep, expandMode: "overlay" as const, diff --git a/src/hooks/use-app-insets.ts b/src/hooks/use-app-insets.ts index 69e0d0a..6c6b286 100644 --- a/src/hooks/use-app-insets.ts +++ b/src/hooks/use-app-insets.ts @@ -1,4 +1,5 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { BrandSpacing } from "@/constants/brand"; export type AppInsets = { safeTop: number; @@ -13,7 +14,7 @@ export function useAppInsets(): AppInsets { const safeBottom = insets.bottom; // Native tabs already reserve baseline content space. Only floating overlays/buttons should // clear the bottom chrome manually. - const overlayBottom = Math.max(safeBottom, 16) + 16; + const overlayBottom = safeBottom + BrandSpacing.lg; return { safeTop, From 109059cc58f509d10d6c53bac7b8dc7c71ecd4d8 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 24 Mar 2026 01:48:12 +0200 Subject: [PATCH 14/44] feat(jobs,map): add archive sheet and studio markers --- convex/jobs.ts | 18 ++ convex/users.ts | 63 +++++ .../(instructor-tabs)/instructor/_layout.tsx | 1 + src/components/jobs/instructor-feed.tsx | 262 +++++++++++------- .../instructor-jobs-archive-sheet.tsx | 211 ++++++++++++++ src/components/map-tab/map-tab/index.tsx | 10 +- .../map-tab/map-tab/map-mobile-stage.tsx | 8 +- .../map-tab/use-map-tab-controller.tsx | 14 +- .../maps/queue-map.native.helpers.ts | 44 ++- src/components/maps/queue-map.native.tsx | 93 ++++++- src/components/maps/queue-map.types.ts | 12 + src/components/ui/icon-symbol.tsx | 1 + src/i18n/translations/en.ts | 4 + src/i18n/translations/he.ts | 4 + 14 files changed, 647 insertions(+), 98 deletions(-) create mode 100644 src/components/jobs/instructor/instructor-jobs-archive-sheet.tsx diff --git a/convex/jobs.ts b/convex/jobs.ts index e8f4fa0..d714c35 100644 --- a/convex/jobs.ts +++ b/convex/jobs.ts @@ -981,6 +981,7 @@ export const getMyApplications = query({ applicationId: v.id("jobApplications"), jobId: v.id("jobs"), instructorId: v.id("instructorProfiles"), + studioId: v.id("studioProfiles"), status: v.union( v.literal("pending"), v.literal("accepted"), @@ -990,6 +991,7 @@ export const getMyApplications = query({ appliedAt: v.number(), message: v.optional(v.string()), studioName: v.string(), + studioImageUrl: v.optional(v.string()), sport: v.string(), zone: v.string(), startTime: v.number(), @@ -1010,6 +1012,9 @@ export const getMyApplications = query({ v.literal("cancelled"), v.literal("completed"), ), + closureReason: v.optional( + v.union(v.literal("expired"), v.literal("studio_cancelled"), v.literal("filled")), + ), }), ), handler: async (ctx, args) => { @@ -1041,13 +1046,23 @@ export const getMyApplications = query({ const studios = await Promise.all( studioIds.map((studioId) => ctx.db.get("studioProfiles", studioId)), ); + const studioImageUrls = await Promise.all( + studios.map((studio) => + studio?.logoStorageId ? ctx.storage.getUrl(studio.logoStorageId) : null, + ), + ); const studioById = new Map>(); + const studioImageUrlById = new Map(); for (let i = 0; i < studioIds.length; i += 1) { const studioId = studioIds[i]; const studio = studios[i]; if (studio) { studioById.set(String(studioId), studio); } + const studioImageUrl = studioImageUrls[i]; + if (studioImageUrl) { + studioImageUrlById.set(String(studioId), studioImageUrl); + } } const paymentDetailsByJobId = await loadLatestPaymentDetailsByJobId(ctx, { @@ -1070,6 +1085,7 @@ export const getMyApplications = query({ applicationId: application._id, jobId: application.jobId, instructorId: application.instructorId, + studioId: job.studioId, status: application.status, appliedAt: application.appliedAt, studioName: studio?.studioName ?? "Unknown studio", @@ -1081,9 +1097,11 @@ export const getMyApplications = query({ jobStatus: job.status, ...omitUndefined({ message: application.message, + studioImageUrl: studioImageUrlById.get(String(job.studioId)), timeZone: job.timeZone, note: job.note, paymentDetails: paymentDetailsByJobId.get(String(job._id)), + closureReason: job.closureReason, }), }); } diff --git a/convex/users.ts b/convex/users.ts index 11501c4..a8f2373 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -10,6 +10,7 @@ import { } from "./lib/auth"; import { normalizeSportType, normalizeZoneId } from "./lib/domainValidation"; import { rebuildInstructorCoverage } from "./lib/instructorCoverage"; +import { loadInstructorEligibility } from "./lib/instructorEligibility"; import { normalizeCoordinates, normalizeOptionalString, @@ -747,6 +748,68 @@ export const getMyStudioSettings = query({ }, }); +export const getInstructorMapStudios = query({ + args: {}, + returns: v.array( + v.object({ + studioId: v.id("studioProfiles"), + studioName: v.string(), + zone: v.string(), + latitude: v.number(), + longitude: v.number(), + address: v.optional(v.string()), + logoImageUrl: v.optional(v.string()), + }), + ), + handler: async (ctx) => { + const user = await getCurrentUserDoc(ctx); + if (!user || !user.isActive || user.role !== "instructor") { + return []; + } + + const instructor = await requireInstructorProfileByUserId(ctx, user._id); + if (!instructor) { + return []; + } + + const eligibility = await loadInstructorEligibility(ctx, instructor._id); + if (eligibility.coverageCount === 0) { + return []; + } + + const zoneIds = [...new Set(eligibility.coveragePairs.map((pair) => pair.zone))]; + const studioGroups = await Promise.all( + zoneIds.map((zoneId) => + ctx.db + .query("studioProfiles") + .withIndex("by_zone", (q) => q.eq("zone", zoneId)) + .collect(), + ), + ); + const studios = [ + ...new Map(studioGroups.flat().map((studio) => [String(studio._id), studio])).values(), + ].filter((studio) => studio.latitude !== undefined && studio.longitude !== undefined); + + const logoUrls = await Promise.all( + studios.map((studio) => + studio.logoStorageId ? ctx.storage.getUrl(studio.logoStorageId) : null, + ), + ); + + return studios.map((studio, index) => ({ + studioId: studio._id, + studioName: studio.studioName, + zone: studio.zone, + latitude: studio.latitude!, + longitude: studio.longitude!, + ...omitUndefined({ + address: studio.address, + logoImageUrl: logoUrls[index] ?? undefined, + }), + })); + }, +}); + export const updateMyStudioCalendarSettings = mutation({ args: { calendarProvider: v.union(v.literal("none"), v.literal("google"), v.literal("apple")), diff --git a/src/app/(app)/(instructor-tabs)/instructor/_layout.tsx b/src/app/(app)/(instructor-tabs)/instructor/_layout.tsx index b02d199..b03076f 100644 --- a/src/app/(app)/(instructor-tabs)/instructor/_layout.tsx +++ b/src/app/(app)/(instructor-tabs)/instructor/_layout.tsx @@ -16,6 +16,7 @@ export default function InstructorTabsLayout() { const instructorTabCounts = useQuery(api.jobs.getInstructorTabCounts, tabCountsArgs); const unreadNotificationCount = useQuery(api.inbox.getMyUnreadNotificationCount, emptyArgs); useQuery(api.instructorZones.getMyInstructorZones, emptyArgs); + useQuery(api.users.getInstructorMapStudios, emptyArgs); const jobsBadgeCount = instructorTabCounts?.jobsBadgeCount ?? 0; const calendarBadgeCount = instructorTabCounts?.calendarBadgeCount ?? 0; diff --git a/src/components/jobs/instructor-feed.tsx b/src/components/jobs/instructor-feed.tsx index 70a7460..3d25879 100644 --- a/src/components/jobs/instructor-feed.tsx +++ b/src/components/jobs/instructor-feed.tsx @@ -1,16 +1,24 @@ +import type BottomSheet from "@gorhom/bottom-sheet"; import { useMutation, useQuery } from "convex/react"; import type { Href } from "expo-router"; import { Redirect, useRouter } from "expo-router"; import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { RefreshControl, StyleSheet, View } from "react-native"; +import Animated, { LinearTransition, ReduceMotion } from "react-native-reanimated"; +import { + type InstructorArchiveRow, + InstructorJobsArchiveSheet, +} from "@/components/jobs/instructor/instructor-jobs-archive-sheet"; import { InstructorOpenJobsList } from "@/components/jobs/instructor/instructor-open-jobs-list"; import { NoticeBanner } from "@/components/jobs/notice-banner"; +import { TabOverlayAnchor } from "@/components/layout/tab-overlay-anchor"; import { TabScreenScrollView } from "@/components/layout/tab-screen-scroll-view"; import { useGlobalTopSheet } from "@/components/layout/top-sheet-registry"; import { useTopSheetContentInsets } from "@/components/layout/use-top-sheet-content-insets"; import { LoadingScreen } from "@/components/loading-screen"; import { ThemedText } from "@/components/themed-text"; +import { IconButton } from "@/components/ui/icon-button"; import { IconSymbol } from "@/components/ui/icon-symbol"; import { KitDisclosureButtonGroup, type KitDisclosureButtonGroupOption } from "@/components/ui/kit"; import { NativeSearchField } from "@/components/ui/native-search-field"; @@ -40,6 +48,7 @@ export function InstructorFeed() { const [applyingJobId, setApplyingJobId] = useState | null>(null); const [applyErrorMessage, setApplyErrorMessage] = useState(null); const refreshTimerRef = useRef | null>(null); + const archiveSheetRef = useRef(null); const deferredJobsSearchQuery = useDeferredValue(jobsSearchQuery); const { contentContainerStyle: sheetContentInsets, progressViewOffset } = useTopSheetContentInsets({ @@ -60,6 +69,10 @@ export function InstructorFeed() { api.jobs.getAvailableJobsForInstructor, currentUser?.role === "instructor" ? { limit: 60, now: queryNow } : "skip", ); + const myApplications = useQuery( + api.jobs.getMyApplications, + currentUser?.role === "instructor" ? { limit: 120 } : "skip", + ); type AvailableJob = NonNullable[number]; @@ -89,6 +102,37 @@ export function InstructorFeed() { return haystack.includes(search); }); }, [deferredJobsSearchQuery, jobs, jobsWindowFilter, queryNow, zoneLanguage]); + const archiveRows = useMemo( + () => + (myApplications ?? []) + .filter((application) => { + if (application.jobStatus === "completed" || application.jobStatus === "cancelled") { + return true; + } + if (application.status === "rejected" || application.status === "withdrawn") { + return true; + } + return application.endTime <= queryNow; + }) + .map((application) => ({ + applicationId: application.applicationId, + jobId: application.jobId, + studioId: application.studioId, + studioName: application.studioName, + sport: application.sport, + zone: application.zone, + startTime: application.startTime, + endTime: application.endTime, + pay: application.pay, + appliedAt: application.appliedAt, + jobStatus: application.jobStatus, + applicationStatus: application.status, + ...(application.studioImageUrl ? { studioImageUrl: application.studioImageUrl } : {}), + ...(application.closureReason ? { closureReason: application.closureReason } : {}), + })) + .sort((left, right) => right.startTime - left.startTime), + [myApplications, queryNow], + ); const handleRefresh = useCallback(() => { if (refreshTimerRef.current) { @@ -122,29 +166,38 @@ export function InstructorFeed() { ] as const satisfies readonly KitDisclosureButtonGroupOption<"all" | "24h" | "72h">[], [t], ); + const headerLayoutTransition = useMemo( + () => LinearTransition.duration(220).reduceMotion(ReduceMotion.System), + [], + ); const jobsSheetConfig = useMemo( () => ({ stickyHeader: ( - - + - + - - + + } size="sm" - railColor={String(palette.primarySubtle)} - selectedColor={String(palette.surface)} + railColor={String(palette.surface)} + selectedColor={String(palette.primarySubtle)} labelColor={String(palette.text)} selectedLabelColor={String(palette.primaryPressed)} dividerColor={String(palette.border)} /> - - + + {applyErrorMessage ? ( setApplyErrorMessage(null)} /> ) : null} - + ), padding: { vertical: BrandSpacing.sm, @@ -195,6 +248,7 @@ export function InstructorFeed() { jobsFilterOptions, jobsWindowFilter, jobsSearchQuery, + headerLayoutTransition, palette, showJobsFilters, t, @@ -252,97 +306,119 @@ export function InstructorFeed() { } return ( - - } - keyboardShouldPersistTaps="handled" - > - - {jobs.length === 0 ? ( - - - - + + + } + keyboardShouldPersistTaps="handled" + > + + {jobs.length === 0 ? ( + + + + + + {t("jobsTab.instructorFeed.emptyInstructorShort")} + + + {emptyJobsCopy} + + + {t("jobsTab.instructorFeed.emptyRefreshHint")} + + + + + ) : filteredAvailableJobs.length === 0 ? ( + + + - {t("jobsTab.instructorFeed.emptyInstructorShort")} - - - {emptyJobsCopy} + {t("jobsTab.noJobsFound")} - {t("jobsTab.instructorFeed.emptyRefreshHint")} + {t("jobsTab.tryDifferentSearchOrTimeFilter")} - - ) : filteredAvailableJobs.length === 0 ? ( - - - - - {t("jobsTab.noJobsFound")} - - - {t("jobsTab.tryDifferentSearchOrTimeFilter")} - - - - ) : ( - - )} - - + ) : ( + + )} + + + + { + archiveSheetRef.current?.expand(); + }} + tone="secondary" + size={58} + backgroundColorOverride={String(palette.surface)} + icon={} + /> + + {}} + rows={archiveRows} + palette={palette} + locale={locale} + zoneLanguage={zoneLanguage} + now={liveNow} + onOpenStudio={onOpenStudio} + /> + ); } diff --git a/src/components/jobs/instructor/instructor-jobs-archive-sheet.tsx b/src/components/jobs/instructor/instructor-jobs-archive-sheet.tsx new file mode 100644 index 0000000..3738698 --- /dev/null +++ b/src/components/jobs/instructor/instructor-jobs-archive-sheet.tsx @@ -0,0 +1,211 @@ +import BottomSheet, { BottomSheetBackdrop, BottomSheetScrollView } from "@gorhom/bottom-sheet"; +import type React from "react"; +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { View } from "react-native"; +import { DotStatusPill } from "@/components/home/home-shared"; +import { + InstructorJobCard, + type InstructorMarketplaceJob, +} from "@/components/jobs/instructor/instructor-job-card"; +import { useCollapsedSheetHeight } from "@/components/layout/scroll-sheet-provider"; +import { ThemedText } from "@/components/themed-text"; +import { AppSymbol } from "@/components/ui/app-symbol"; +import { IconButton } from "@/components/ui/icon-button"; +import type { BrandPalette } from "@/constants/brand"; +import { BrandSpacing } from "@/constants/brand"; +import type { Id } from "@/convex/_generated/dataModel"; +import { + getApplicationStatusTranslationKey, + getJobStatusToneWithReason, + getJobStatusTranslationKey, + type JobClosureReason, +} from "@/lib/jobs-utils"; + +export type InstructorArchiveRow = InstructorMarketplaceJob & { + applicationId: Id<"jobApplications">; + appliedAt: number; + jobStatus: "open" | "filled" | "cancelled" | "completed"; + closureReason?: JobClosureReason; +}; + +type InstructorJobsArchiveSheetProps = { + innerRef: React.RefObject; + onDismissed: () => void; + rows: InstructorArchiveRow[]; + palette: BrandPalette; + locale: string; + zoneLanguage: "en" | "he"; + now: number; + onOpenStudio: (studioId: Id<"studioProfiles">, jobId: Id<"jobs">) => void; +}; + +function formatArchiveDate(locale: string, timestamp: number) { + return new Intl.DateTimeFormat(locale, { + month: "short", + day: "numeric", + }).format(timestamp); +} + +function getStatusColors( + tone: "primary" | "success" | "gray" | "amber" | "muted", + palette: BrandPalette, +) { + if (tone === "success") { + return { + backgroundColor: palette.successSubtle as string, + color: palette.success as string, + }; + } + if (tone === "amber") { + return { + backgroundColor: palette.warningSubtle as string, + color: palette.warning as string, + }; + } + if (tone === "gray" || tone === "muted") { + return { + backgroundColor: palette.surfaceAlt as string, + color: palette.textMuted as string, + }; + } + return { + backgroundColor: palette.primarySubtle as string, + color: palette.primary as string, + }; +} + +export function InstructorJobsArchiveSheet({ + innerRef, + onDismissed, + rows, + palette, + locale, + zoneLanguage, + now, + onOpenStudio, +}: InstructorJobsArchiveSheetProps) { + const { t } = useTranslation(); + const collapsedSheetHeight = useCollapsedSheetHeight(); + const snapPoints = ["82%"]; + + const renderBackdrop = useCallback( + (props: any) => ( + + ), + [palette.surface], + ); + + return ( + + + + + {t("jobsTab.archiveTitle")} + + {t("jobsTab.instructorFeed.archiveSubtitle")} + + + innerRef.current?.close()} + size={BrandSpacing.controlSm} + tone="secondary" + backgroundColorOverride={String(palette.surfaceAlt)} + icon={} + /> + + + {rows.length === 0 ? ( + + + {t("jobsTab.instructorFeed.archiveEmpty")} + + + ) : ( + rows.map((row) => { + const jobStatusTone = getJobStatusToneWithReason(row.jobStatus, row.closureReason); + const jobStatusColors = getStatusColors(jobStatusTone, palette); + + return ( + + + + + + + {t("jobsTab.instructorFeed.archiveAppliedOn", { + date: formatArchiveDate(locale, row.appliedAt), + })} + + + + ); + }) + )} + + + ); +} diff --git a/src/components/map-tab/map-tab/index.tsx b/src/components/map-tab/map-tab/index.tsx index d02f129..7dc7571 100644 --- a/src/components/map-tab/map-tab/index.tsx +++ b/src/components/map-tab/map-tab/index.tsx @@ -1,4 +1,5 @@ -import { Redirect } from "expo-router"; +import type { Href } from "expo-router"; +import { Redirect, useRouter } from "expo-router"; import { Platform, View } from "react-native"; import { TabScreenRoot } from "@/components/layout/tab-screen-root"; import { MapMobileStage } from "@/components/map-tab/map-tab/map-mobile-stage"; @@ -6,6 +7,7 @@ import { MapWebWorkbench } from "@/components/map-tab/map-tab/map-web-workbench" import { useMapTabController } from "@/components/map-tab/map-tab/use-map-tab-controller"; export default function MapTabScreen() { + const router = useRouter(); const { currentUser, filteredZones, @@ -23,6 +25,7 @@ export default function MapTabScreen() { mapCameraPadding, mapPalette, mapPin, + studios, noopMapPress, palette, pendingChangeCount, @@ -37,6 +40,9 @@ export default function MapTabScreen() { zoneSearch, zoneModeActive, } = useMapTabController(); + const handlePressStudio = (studioId: string) => { + router.push(`/instructor/jobs/studios/${encodeURIComponent(studioId)}` as Href); + }; if (currentUser === undefined) { return ( @@ -110,6 +116,7 @@ export default function MapTabScreen() { mapBackgroundColor={mapPalette.styleBackground} isFocused={isFocused} mapPin={mapPin} + studios={studios} selectedZoneIds={selectedZoneIds} focusZoneId={focusZoneId} zoneModeActive={zoneModeActive} @@ -117,6 +124,7 @@ export default function MapTabScreen() { cameraPadding={mapCameraPadding} onPressZone={toggleZone} onPressMap={noopMapPress} + onPressStudio={handlePressStudio} onEditToggle={handleEditButtonPress} /> ); diff --git a/src/components/map-tab/map-tab/map-mobile-stage.tsx b/src/components/map-tab/map-tab/map-mobile-stage.tsx index b649724..5dbdb7b 100644 --- a/src/components/map-tab/map-tab/map-mobile-stage.tsx +++ b/src/components/map-tab/map-tab/map-mobile-stage.tsx @@ -3,7 +3,7 @@ import { View } from "react-native"; import { TabOverlayAnchor } from "@/components/layout/tab-overlay-anchor"; import { QueueMap } from "@/components/maps/queue-map"; -import type { QueueMapPin } from "@/components/maps/queue-map.types"; +import type { QueueMapPin, StudioMapMarker } from "@/components/maps/queue-map.types"; import { IconButton } from "@/components/ui/icon-button"; import { IconSymbol } from "@/components/ui/icon-symbol"; import { type BrandPalette, BrandSpacing } from "@/constants/brand"; @@ -14,6 +14,7 @@ type MapMobileStageProps = { mapBackgroundColor: string; isFocused: boolean; mapPin: QueueMapPin | null; + studios: StudioMapMarker[]; selectedZoneIds: string[]; focusZoneId: string | null; zoneModeActive: boolean; @@ -26,6 +27,7 @@ type MapMobileStageProps = { }; onPressZone: (zoneId: string) => void; onPressMap: () => void; + onPressStudio: (studioId: string) => void; onEditToggle: () => void; }; @@ -35,6 +37,7 @@ export function MapMobileStage({ mapBackgroundColor, isFocused, mapPin, + studios, selectedZoneIds, focusZoneId, zoneModeActive, @@ -42,6 +45,7 @@ export function MapMobileStage({ cameraPadding, onPressZone, onPressMap, + onPressStudio, onEditToggle, }: MapMobileStageProps) { if (!isFocused) { @@ -53,12 +57,14 @@ export function MapMobileStage({ diff --git a/src/components/map-tab/map-tab/use-map-tab-controller.tsx b/src/components/map-tab/map-tab/use-map-tab-controller.tsx index 76b9c0f..56dd2a6 100644 --- a/src/components/map-tab/map-tab/use-map-tab-controller.tsx +++ b/src/components/map-tab/map-tab/use-map-tab-controller.tsx @@ -10,7 +10,7 @@ import { useGlobalTopSheet } from "@/components/layout/top-sheet-registry"; import { useDeferredTabMount } from "@/components/layout/use-deferred-tab-mount"; import { MapSheetResults } from "@/components/map-tab/map/map-sheet-results"; import { buildZoneCityGroups, buildZoneCityListItems } from "@/components/map-tab/zone-city-tree"; -import type { QueueMapPin } from "@/components/maps/queue-map.types"; +import type { QueueMapPin, StudioMapMarker } from "@/components/maps/queue-map.types"; import { BrandSpacing, getMapBrandPalette } from "@/constants/brand"; import { ZONE_OPTIONS } from "@/constants/zones"; import { useUser } from "@/contexts/user-context"; @@ -58,6 +58,10 @@ export function useMapTabController() { api.instructorZones.getMyInstructorZones, currentUser?.role === "instructor" ? {} : "skip", ); + const remoteStudios = useQuery( + api.users.getInstructorMapStudios, + currentUser?.role === "instructor" ? {} : "skip", + ); const saveZones = useMutation(api.instructorZones.setMyInstructorZones); const [selectedZoneIds, setSelectedZoneIds] = useState([]); @@ -179,6 +183,13 @@ export function useMapTabController() { .filter((zone): zone is NonNullable => Boolean(zone)), [selectedZoneIds], ); + const visibleStudioMarkers = useMemo( + () => + (remoteStudios ?? []).filter((studio) => + selectedZoneIds.length > 0 ? selectedZoneIds.includes(studio.zone) : true, + ), + [remoteStudios, selectedZoneIds], + ); const focusedZone = useMemo( () => (focusZoneId ? (STATIC_ZONE_BY_ID.get(focusZoneId) ?? null) : null), [focusZoneId], @@ -438,6 +449,7 @@ export function useMapTabController() { mapCameraPadding, mapPalette, mapPin, + studios: visibleStudioMarkers, noopMapPress, overlayBottom, palette, diff --git a/src/components/maps/queue-map.native.helpers.ts b/src/components/maps/queue-map.native.helpers.ts index 0eece3a..27cf6f3 100644 --- a/src/components/maps/queue-map.native.helpers.ts +++ b/src/components/maps/queue-map.native.helpers.ts @@ -3,7 +3,7 @@ import { OfflineManager } from "@maplibre/maplibre-react-native"; import { APPLE_MAP_THEME } from "@/components/maps/queue-map-apple-theme"; import type { getMapBrandPalette } from "@/constants/brand"; import { ISRAEL_MAP_INTERACTION_BOUNDS } from "@/constants/zones-map"; -import type { QueueMapPin } from "./queue-map.types"; +import type { QueueMapPin, StudioMapMarker } from "./queue-map.types"; export type Expression = unknown; export type AnyStyleLayer = Record; @@ -238,6 +238,48 @@ export function createPinShape(pin: QueueMapPin | null): GeoJSON.FeatureCollecti }; } +const STUDIO_MARKER_IMAGE_PREFIX = "studio-marker:"; + +export function getStudioMarkerImageEntries(studios: readonly StudioMapMarker[]) { + return Object.fromEntries( + studios + .filter((studio) => typeof studio.logoImageUrl === "string" && studio.logoImageUrl.length > 0) + .map((studio) => [ + `${STUDIO_MARKER_IMAGE_PREFIX}${studio.studioId}`, + studio.logoImageUrl as string, + ]), + ); +} + +export function createStudioMarkersGeoJson( + studios: readonly StudioMapMarker[], + variant: "logo" | "fallback", +): GeoJSON.FeatureCollection { + return { + type: "FeatureCollection", + features: studios + .filter((studio) => + variant === "logo" ? Boolean(studio.logoImageUrl) : !studio.logoImageUrl, + ) + .map((studio) => ({ + type: "Feature" as const, + properties: { + studioId: studio.studioId, + studioName: studio.studioName, + zone: studio.zone, + label: studio.studioName.slice(0, 1).toUpperCase(), + ...(variant === "logo" + ? { iconKey: `${STUDIO_MARKER_IMAGE_PREFIX}${studio.studioId}` } + : {}), + }, + geometry: { + type: "Point" as const, + coordinates: [studio.longitude, studio.latitude], + }, + })), + }; +} + export function resolveThemedMapStyle( cacheKey: string, baseMapStyle: AnyStyleSpec | null, diff --git a/src/components/maps/queue-map.native.tsx b/src/components/maps/queue-map.native.tsx index c8579ab..8a6f120 100644 --- a/src/components/maps/queue-map.native.tsx +++ b/src/components/maps/queue-map.native.tsx @@ -1,4 +1,10 @@ -import { Camera, GeoJSONSource, Layer, Map as MapLibreMap } from "@maplibre/maplibre-react-native"; +import { + Camera, + GeoJSONSource, + Images, + Layer, + Map as MapLibreMap, +} from "@maplibre/maplibre-react-native"; import Constants from "expo-constants"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -17,10 +23,12 @@ import { KitSurface } from "../ui/kit"; import { type AnyStyleSpec, createPinShape, + createStudioMarkersGeoJson, createZoneFilter, ensureVectorOfflinePack, fetchMapStyleSpec, getCachedMapStyleSpec, + getStudioMarkerImageEntries, resolveThemedMapStyle, sanitizeZoom, toBounds, @@ -42,6 +50,7 @@ const MAP_LOADING_OVERLAY_DELAY_MS = 180; export const QueueMap = memo(function QueueMap({ mode, pin, + studios = [], selectedZoneIds, focusZoneId, isEditing = mode === "zoneSelect", @@ -49,6 +58,7 @@ export const QueueMap = memo(function QueueMap({ zoneIdProperty = "id", onPressZone, onPressMap, + onPressStudio, onUseGps, showGpsButton = true, showAttributionButton = true, @@ -98,6 +108,12 @@ export const QueueMap = memo(function QueueMap({ [selectedZoneIds, zoneIdProperty], ); const pinShape = useMemo(() => createPinShape(pin), [pin]); + const studioMarkerImages = useMemo(() => getStudioMarkerImageEntries(studios), [studios]); + const studioLogoShape = useMemo(() => createStudioMarkersGeoJson(studios, "logo"), [studios]); + const studioFallbackShape = useMemo( + () => createStudioMarkersGeoJson(studios, "fallback"), + [studios], + ); const handleRetry = useCallback(() => { setBaseMapStyle(null); setMapErrorMessage(null); @@ -319,6 +335,81 @@ export const QueueMap = memo(function QueueMap({ onPressZone={onPressZone} /> + {Object.keys(studioMarkerImages).length > 0 ? : null} + + { + if (!onPressStudio) return; + const native = event?.nativeEvent ?? event; + const studioId = native?.features?.[0]?.properties?.studioId; + if (typeof studioId === "string") { + onPressStudio(studioId); + } + }} + > + + + + + { + if (!onPressStudio) return; + const native = event?.nativeEvent ?? event; + const studioId = native?.features?.[0]?.properties?.studioId; + if (typeof studioId === "string") { + onPressStudio(studioId); + } + }} + > + + + + void; onPressMap?: (pin: QueueMapPin) => void; + onPressStudio?: (studioId: string) => void; onUseGps?: () => void; showGpsButton?: boolean; showAttributionButton?: boolean; diff --git a/src/components/ui/icon-symbol.tsx b/src/components/ui/icon-symbol.tsx index fb76731..c3cf71c 100644 --- a/src/components/ui/icon-symbol.tsx +++ b/src/components/ui/icon-symbol.tsx @@ -17,6 +17,7 @@ type IconSymbolName = keyof typeof MAPPING; * - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app. */ const MAPPING = { + "archivebox.fill": "archive", "arrow.clockwise": "autorenew", "arrow.down": "south", "arrow.right": "arrow-forward", diff --git a/src/i18n/translations/en.ts b/src/i18n/translations/en.ts index fe4d85e..5bf15cf 100644 --- a/src/i18n/translations/en.ts +++ b/src/i18n/translations/en.ts @@ -1153,6 +1153,10 @@ const en = { emptyInstructorFreshTwo: "The board is quiet.", emptyInstructorFreshThree: "Check back in a bit.", emptyRefreshHint: "Pull to refresh for a new line.", + openArchive: "Open archive", + archiveSubtitle: "Previous applications and past jobs stay here.", + archiveEmpty: "No archived jobs yet.", + archiveAppliedOn: "Applied {{date}}", }, studioFeed: { eyebrow: "Studio operations", diff --git a/src/i18n/translations/he.ts b/src/i18n/translations/he.ts index c1fc3ce..e3b3467 100644 --- a/src/i18n/translations/he.ts +++ b/src/i18n/translations/he.ts @@ -1090,6 +1090,10 @@ const he = { emptyInstructorFreshTwo: "הלוח שקט כרגע.", emptyInstructorFreshThree: "נסו שוב עוד מעט.", emptyRefreshHint: "משכו לרענון כדי לקבל שורה חדשה.", + openArchive: "פתיחת ארכיון", + archiveSubtitle: "פניות קודמות ומשרות שעברו נשארות כאן.", + archiveEmpty: "עדיין אין משרות בארכיון.", + archiveAppliedOn: "הוגש ב־{{date}}", }, studioFeed: { eyebrow: "תפעול סטודיו", From d7bec3dc2e7b1998679d0cef516b4c2859743732 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 24 Mar 2026 01:57:07 +0200 Subject: [PATCH 15/44] refine archive sheet and overlay spacing --- .../instructor-jobs-archive-sheet.tsx | 237 +++++++++++++----- .../jobs/studio/create-job-sheet.tsx | 11 +- src/components/ui/icon-symbol.tsx | 1 + src/hooks/use-app-insets.ts | 6 +- 4 files changed, 191 insertions(+), 64 deletions(-) diff --git a/src/components/jobs/instructor/instructor-jobs-archive-sheet.tsx b/src/components/jobs/instructor/instructor-jobs-archive-sheet.tsx index 3738698..8881219 100644 --- a/src/components/jobs/instructor/instructor-jobs-archive-sheet.tsx +++ b/src/components/jobs/instructor/instructor-jobs-archive-sheet.tsx @@ -1,9 +1,8 @@ import BottomSheet, { BottomSheetBackdrop, BottomSheetScrollView } from "@gorhom/bottom-sheet"; import type React from "react"; -import { useCallback } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { View } from "react-native"; -import { DotStatusPill } from "@/components/home/home-shared"; +import { Pressable, View } from "react-native"; import { InstructorJobCard, type InstructorMarketplaceJob, @@ -12,11 +11,11 @@ import { useCollapsedSheetHeight } from "@/components/layout/scroll-sheet-provid import { ThemedText } from "@/components/themed-text"; import { AppSymbol } from "@/components/ui/app-symbol"; import { IconButton } from "@/components/ui/icon-button"; -import type { BrandPalette } from "@/constants/brand"; -import { BrandSpacing } from "@/constants/brand"; +import { IconSymbol } from "@/components/ui/icon-symbol"; +import { type BrandPalette, BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; import type { Id } from "@/convex/_generated/dataModel"; +import { toSportLabel } from "@/convex/constants"; import { - getApplicationStatusTranslationKey, getJobStatusToneWithReason, getJobStatusTranslationKey, type JobClosureReason, @@ -47,7 +46,7 @@ function formatArchiveDate(locale: string, timestamp: number) { }).format(timestamp); } -function getStatusColors( +function getStatusTokens( tone: "primary" | "success" | "gray" | "amber" | "muted", palette: BrandPalette, ) { @@ -75,6 +74,151 @@ function getStatusColors( }; } +function ArchiveStatusChip({ + label, + palette, + tone, +}: { + label: string; + palette: BrandPalette; + tone: "primary" | "success" | "gray" | "amber" | "muted"; +}) { + const tokens = getStatusTokens(tone, palette); + + return ( + + + {label} + + + ); +} + +function ArchiveCompactRow({ + expanded, + locale, + onOpenStudio, + onToggle, + palette, + row, + t, + zoneLanguage, + now, +}: { + expanded: boolean; + locale: string; + onOpenStudio: (studioId: Id<"studioProfiles">, jobId: Id<"jobs">) => void; + onToggle: () => void; + palette: BrandPalette; + row: InstructorArchiveRow; + t: ReturnType["t"]; + zoneLanguage: "en" | "he"; + now: number; +}) { + const sportLabel = useMemo(() => toSportLabel(row.sport as never), [row.sport]); + const statusTone = getJobStatusToneWithReason(row.jobStatus, row.closureReason); + const statusLabel = t(getJobStatusTranslationKey(row.jobStatus, row.closureReason)); + + return ( + + ({ + backgroundColor: pressed ? (palette.surfaceAlt as string) : (palette.surface as string), + })} + > + + + + {formatArchiveDate(locale, row.startTime)} + + + {t("jobsTab.instructorFeed.archiveAppliedOn", { + date: formatArchiveDate(locale, row.appliedAt), + })} + + + + + {sportLabel} + + + {row.studioName} + + + + + + + {expanded ? ( + + + + ) : null} + + ); +} + export function InstructorJobsArchiveSheet({ innerRef, onDismissed, @@ -87,7 +231,8 @@ export function InstructorJobsArchiveSheet({ }: InstructorJobsArchiveSheetProps) { const { t } = useTranslation(); const collapsedSheetHeight = useCollapsedSheetHeight(); - const snapPoints = ["82%"]; + const [expandedApplicationId, setExpandedApplicationId] = useState(null); + const snapPoints = ["78%"]; const renderBackdrop = useCallback( (props: any) => ( @@ -95,12 +240,16 @@ export function InstructorJobsArchiveSheet({ {...props} disappearsAt={-1} appearsAt={0} - style={[props.style, { backgroundColor: palette.surface as string }]} + style={[props.style, { backgroundColor: palette.appBg as string }]} /> ), - [palette.surface], + [palette.appBg], ); + const toggleExpanded = useCallback((applicationId: string) => { + setExpandedApplicationId((current) => (current === applicationId ? null : applicationId)); + }, []); + return ( { + setExpandedApplicationId(null); + onDismissed(); + }} backdropComponent={renderBackdrop} handleIndicatorStyle={{ backgroundColor: palette.borderStrong as string }} - backgroundStyle={{ backgroundColor: palette.appBg as string }} + backgroundStyle={{ backgroundColor: palette.surfaceElevated as string }} > ) : ( - rows.map((row) => { - const jobStatusTone = getJobStatusToneWithReason(row.jobStatus, row.closureReason); - const jobStatusColors = getStatusColors(jobStatusTone, palette); - - return ( - - - - - - - {t("jobsTab.instructorFeed.archiveAppliedOn", { - date: formatArchiveDate(locale, row.appliedAt), - })} - - - - ); - }) + rows.map((row) => ( + toggleExpanded(String(row.applicationId))} + palette={palette} + row={row} + t={t} + zoneLanguage={zoneLanguage} + now={now} + /> + )) )} diff --git a/src/components/jobs/studio/create-job-sheet.tsx b/src/components/jobs/studio/create-job-sheet.tsx index 4558984..9949cf6 100644 --- a/src/components/jobs/studio/create-job-sheet.tsx +++ b/src/components/jobs/studio/create-job-sheet.tsx @@ -83,9 +83,14 @@ export function CreateJobSheet({ const renderBackdrop = useCallback( (props: any) => ( - + ), - [], + [palette.appBg], ); const handleDateChange = (_event: any, selectedDate?: Date) => { @@ -162,7 +167,7 @@ export function CreateJobSheet({ onClose={handleDismissed} backdropComponent={renderBackdrop} handleIndicatorStyle={{ backgroundColor: palette.borderStrong as string }} - backgroundStyle={{ backgroundColor: palette.appBg as string }} + backgroundStyle={{ backgroundColor: palette.surfaceElevated as string }} > diff --git a/src/components/ui/icon-symbol.tsx b/src/components/ui/icon-symbol.tsx index c3cf71c..c843c11 100644 --- a/src/components/ui/icon-symbol.tsx +++ b/src/components/ui/icon-symbol.tsx @@ -56,6 +56,7 @@ const MAPPING = { sparkles: "auto-awesome", "checkmark.circle.fill": "check-circle", banknote: "payments", + "chevron.down": "expand-more", "location.fill": "my-location", "line.3.horizontal.decrease.circle": "filter-list", "paperplane.fill": "send", diff --git a/src/hooks/use-app-insets.ts b/src/hooks/use-app-insets.ts index 6c6b286..f3d86fe 100644 --- a/src/hooks/use-app-insets.ts +++ b/src/hooks/use-app-insets.ts @@ -12,9 +12,9 @@ export function useAppInsets(): AppInsets { const safeTop = insets.top; const safeBottom = insets.bottom; - // Native tabs already reserve baseline content space. Only floating overlays/buttons should - // clear the bottom chrome manually. - const overlayBottom = safeBottom + BrandSpacing.lg; + // Native tabs already account for bottom safe area. Floating controls should use the same + // semantic gutter on both axes instead of double-counting the bottom inset. + const overlayBottom = BrandSpacing.lg; return { safeTop, From aa8e5758a67d9c519506f33d9b1b1380c01532cc Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 24 Mar 2026 02:07:01 +0200 Subject: [PATCH 16/44] Polish map markers and archive sheet chrome --- src/components/jobs/instructor-feed.tsx | 22 +- .../instructor-jobs-archive-sheet.tsx | 24 ++- .../map-tab/map-tab/map-sheet-header.tsx | 6 +- .../map-tab/map/map-selected-zones-strip.tsx | 8 +- .../map-tab/map/map-sheet-results.tsx | 14 +- src/components/maps/queue-map.native.tsx | 198 ++++++++++-------- 6 files changed, 162 insertions(+), 110 deletions(-) diff --git a/src/components/jobs/instructor-feed.tsx b/src/components/jobs/instructor-feed.tsx index 3d25879..10721f9 100644 --- a/src/components/jobs/instructor-feed.tsx +++ b/src/components/jobs/instructor-feed.tsx @@ -43,6 +43,7 @@ export function InstructorFeed() { const [jobsSearchQuery, setJobsSearchQuery] = useState(""); const [jobsWindowFilter, setJobsWindowFilter] = useState<"all" | "24h" | "72h">("all"); const [showJobsFilters, setShowJobsFilters] = useState(false); + const [isArchiveOpen, setIsArchiveOpen] = useState(false); const [emptyVariantIndex, setEmptyVariantIndex] = useState(0); const [refreshing, setRefreshing] = useState(false); const [applyingJobId, setApplyingJobId] = useState | null>(null); @@ -400,17 +401,30 @@ export function InstructorFeed() { { + if (isArchiveOpen) { + archiveSheetRef.current?.close(); + return; + } archiveSheetRef.current?.expand(); }} - tone="secondary" + tone={isArchiveOpen ? "primary" : "secondary"} size={58} - backgroundColorOverride={String(palette.surface)} - icon={} + backgroundColorOverride={String(isArchiveOpen ? palette.primary : palette.surface)} + icon={ + + } /> {}} + onDismissed={() => { + setIsArchiveOpen(false); + }} + onOpenStateChange={setIsArchiveOpen} rows={archiveRows} palette={palette} locale={locale} diff --git a/src/components/jobs/instructor/instructor-jobs-archive-sheet.tsx b/src/components/jobs/instructor/instructor-jobs-archive-sheet.tsx index 8881219..3bc8043 100644 --- a/src/components/jobs/instructor/instructor-jobs-archive-sheet.tsx +++ b/src/components/jobs/instructor/instructor-jobs-archive-sheet.tsx @@ -31,6 +31,7 @@ export type InstructorArchiveRow = InstructorMarketplaceJob & { type InstructorJobsArchiveSheetProps = { innerRef: React.RefObject; onDismissed: () => void; + onOpenStateChange?: (open: boolean) => void; rows: InstructorArchiveRow[]; palette: BrandPalette; locale: string; @@ -132,7 +133,7 @@ function ArchiveCompactRow({ style={{ borderRadius: BrandRadius.soft, borderCurve: "continuous", - backgroundColor: palette.surface as string, + backgroundColor: palette.surfaceAlt as string, overflow: "hidden", }} > @@ -141,7 +142,9 @@ function ArchiveCompactRow({ accessibilityLabel={`${row.studioName} ${sportLabel}`} onPress={onToggle} style={({ pressed }) => ({ - backgroundColor: pressed ? (palette.surfaceAlt as string) : (palette.surface as string), + backgroundColor: pressed + ? (palette.surfaceElevated as string) + : (palette.surfaceAlt as string), })} > @@ -222,6 +225,7 @@ function ArchiveCompactRow({ export function InstructorJobsArchiveSheet({ innerRef, onDismissed, + onOpenStateChange, rows, palette, locale, @@ -257,13 +261,21 @@ export function InstructorJobsArchiveSheet({ snapPoints={snapPoints} topInset={collapsedSheetHeight} enablePanDownToClose + onChange={(index) => { + onOpenStateChange?.(index >= 0); + }} onClose={() => { + onOpenStateChange?.(false); setExpandedApplicationId(null); onDismissed(); }} backdropComponent={renderBackdrop} handleIndicatorStyle={{ backgroundColor: palette.borderStrong as string }} - backgroundStyle={{ backgroundColor: palette.surfaceElevated as string }} + backgroundStyle={{ + backgroundColor: palette.surface as string, + borderTopWidth: BrandSpacing.xxs, + borderTopColor: palette.borderStrong as string, + }} > innerRef.current?.close()} size={BrandSpacing.controlSm} tone="secondary" - backgroundColorOverride={String(palette.surfaceAlt)} - icon={} + backgroundColorOverride={String(palette.primarySubtle)} + icon={} /> diff --git a/src/components/map-tab/map-tab/map-sheet-header.tsx b/src/components/map-tab/map-tab/map-sheet-header.tsx index 71900f1..a44ae48 100644 --- a/src/components/map-tab/map-tab/map-sheet-header.tsx +++ b/src/components/map-tab/map-tab/map-sheet-header.tsx @@ -24,7 +24,7 @@ export function MapSheetHeader({ onChangeSearch, onFocusSearch, palette, - mapPalette, + mapPalette: _mapPalette, selectedZones, onPressZone, t, @@ -40,14 +40,14 @@ export function MapSheetHeader({ placeholder={t("mapTab.searchPlaceholder")} clearAccessibilityLabel={t("common.clear")} size="sm" - containerStyle={{ backgroundColor: mapPalette.surfaceAlt as string }} + containerStyle={{ backgroundColor: palette.surfaceAlt as string }} /> diff --git a/src/components/map-tab/map/map-selected-zones-strip.tsx b/src/components/map-tab/map/map-selected-zones-strip.tsx index a42f43f..b2cae3a 100644 --- a/src/components/map-tab/map/map-selected-zones-strip.tsx +++ b/src/components/map-tab/map/map-selected-zones-strip.tsx @@ -27,7 +27,7 @@ export function MapSelectedZonesStrip({ focusZoneId, zoneLanguage, palette, - mapPalette, + mapPalette: _mapPalette, onPressZone, }: MapSelectedZonesStripProps) { const { t } = useTranslation(); @@ -55,8 +55,8 @@ export function MapSelectedZonesStrip({ compact fullWidth={false} onPress={() => onPressZone(zone.id)} - backgroundColor={mapPalette.surfaceAlt} - selectedBackgroundColor={palette.surfaceElevated} + backgroundColor={palette.surfaceAlt} + selectedBackgroundColor={palette.primarySubtle} labelColor={palette.text} selectedLabelColor={palette.primary} style={{ @@ -75,7 +75,7 @@ export function MapSelectedZonesStrip({ borderCurve: "continuous", paddingHorizontal: BrandSpacing.md, paddingVertical: BrandSpacing.xs, - backgroundColor: mapPalette.surfaceAlt as string, + backgroundColor: palette.surfaceAlt as string, justifyContent: "center", }} > diff --git a/src/components/map-tab/map/map-sheet-results.tsx b/src/components/map-tab/map/map-sheet-results.tsx index 1ec688c..96640a4 100644 --- a/src/components/map-tab/map/map-sheet-results.tsx +++ b/src/components/map-tab/map/map-sheet-results.tsx @@ -35,7 +35,7 @@ export function MapSheetResults({ zoneLanguage, zoneModeActive, palette, - mapPalette, + mapPalette: _mapPalette, onPressZone, onPressCity, onToggleCityExpanded, @@ -86,7 +86,7 @@ export function MapSheetResults({ paddingHorizontal: BrandSpacing.lg, paddingVertical: BrandSpacing.lg, gap: BrandSpacing.xs, - backgroundColor: mapPalette.surfaceAlt as string, + backgroundColor: palette.surfaceAlt as string, }} > @@ -108,8 +108,8 @@ export function MapSheetResults({ borderRadius: MAP_RESULT_RADIUS, borderCurve: "continuous", backgroundColor: item.selected - ? (palette.surfaceElevated as string) - : (mapPalette.surfaceAlt as string), + ? (palette.primarySubtle as string) + : (palette.surfaceAlt as string), }} > = STUDIO_MARKER_CLOSE_ZOOM) { + return { + outerSize: BrandSpacing.controlLg, + imageSize: BrandSpacing.controlMd, + tailSize: BrandSpacing.md, + }; + } + + if (zoom >= STUDIO_MARKER_NEAR_ZOOM) { + return { + outerSize: BrandSpacing.avatarMd, + imageSize: BrandSpacing.controlSm, + tailSize: BrandSpacing.sm + BrandSpacing.xxs, + }; + } + + return { + outerSize: BrandSpacing.iconContainer + BrandSpacing.sm, + imageSize: BrandSpacing.iconContainer - BrandSpacing.xs, + tailSize: BrandSpacing.sm + BrandSpacing.xxs, + }; +} + export const QueueMap = memo(function QueueMap({ mode, pin, @@ -75,6 +103,9 @@ export const QueueMap = memo(function QueueMap({ const [mapErrorMessage, setMapErrorMessage] = useState(null); const [baseMapStyle, setBaseMapStyle] = useState(null); const [showLabelLayers, setShowLabelLayers] = useState(false); + const [currentZoom, setCurrentZoom] = useState( + pin ? APPLE_MAP_THEME.defaultZoomWithPin : APPLE_MAP_THEME.defaultZoomWithoutPin, + ); const preferredStyleUrl = resolvedScheme === "dark" ? APPLE_MAP_THEME.mapStyleDarkUrl : APPLE_MAP_THEME.mapStyleLightUrl; const styleFetchUrl = @@ -95,9 +126,7 @@ export const QueueMap = memo(function QueueMap({ const mapStyle = themedMapStyle ?? preferredStyleUrl; const mapKey = `${resolvedScheme}:${retryNonce}`; - const mapRef = useRef<{ - showAttribution?: () => void; - } | null>(null); + const mapRef = useRef(null); const mapLoadStateRef = useRef("loading"); const cameraRef = useRef<{ setStop: (config: unknown) => void; @@ -108,12 +137,8 @@ export const QueueMap = memo(function QueueMap({ [selectedZoneIds, zoneIdProperty], ); const pinShape = useMemo(() => createPinShape(pin), [pin]); - const studioMarkerImages = useMemo(() => getStudioMarkerImageEntries(studios), [studios]); - const studioLogoShape = useMemo(() => createStudioMarkersGeoJson(studios, "logo"), [studios]); - const studioFallbackShape = useMemo( - () => createStudioMarkersGeoJson(studios, "fallback"), - [studios], - ); + const studioMarkerMetrics = useMemo(() => getStudioMarkerMetrics(currentZoom), [currentZoom]); + const showStudioMarkers = studios.length > 0 && currentZoom >= STUDIO_MARKER_MIN_ZOOM; const handleRetry = useCallback(() => { setBaseMapStyle(null); setMapErrorMessage(null); @@ -304,6 +329,10 @@ export const QueueMap = memo(function QueueMap({ onDidFailLoadingMap={() => { updateMapLoadState("error", t("mapTab.native.unavailableBody")); }} + onRegionDidChange={(event) => { + const nextZoom = sanitizeZoom(event.nativeEvent.zoom, currentZoom); + setCurrentZoom((current) => (Math.abs(current - nextZoom) < 0.05 ? current : nextZoom)); + }} onPress={(event: any) => { if (mode !== "pinDrop") return; if (!onPressMap) return; @@ -335,80 +364,77 @@ export const QueueMap = memo(function QueueMap({ onPressZone={onPressZone} /> - {Object.keys(studioMarkerImages).length > 0 ? : null} - - { - if (!onPressStudio) return; - const native = event?.nativeEvent ?? event; - const studioId = native?.features?.[0]?.properties?.studioId; - if (typeof studioId === "string") { - onPressStudio(studioId); - } - }} - > - - - - - { - if (!onPressStudio) return; - const native = event?.nativeEvent ?? event; - const studioId = native?.features?.[0]?.properties?.studioId; - if (typeof studioId === "string") { - onPressStudio(studioId); - } - }} - > - - - + {showStudioMarkers + ? studios.map((studio) => { + const markerSize = studioMarkerMetrics.outerSize; + const imageSize = studioMarkerMetrics.imageSize; + const tailSize = studioMarkerMetrics.tailSize; + const hasLogo = + typeof studio.logoImageUrl === "string" && studio.logoImageUrl.length > 0; + + return ( + + + + onPressStudio?.(studio.studioId)} + style={({ pressed }) => ({ + width: markerSize, + height: markerSize, + borderRadius: markerSize / 2, + borderCurve: "continuous", + borderWidth: STUDIO_MARKER_BORDER_WIDTH, + borderColor: palette.primary as string, + alignItems: "center", + justifyContent: "center", + overflow: "hidden", + backgroundColor: pressed + ? (palette.primarySubtle as string) + : (palette.surfaceElevated as string), + })} + > + {hasLogo ? ( + + ) : ( + + {studio.studioName.slice(0, 1).toUpperCase()} + + )} + + + + ); + }) + : null} Date: Tue, 24 Mar 2026 02:16:34 +0200 Subject: [PATCH 17/44] Refine archive sheet details and floating controls --- src/components/jobs/instructor-feed.tsx | 4 +- .../instructor-jobs-archive-sheet.tsx | 227 ++++++++++++++---- .../map-tab/map-tab/map-mobile-stage.tsx | 1 + src/components/ui/icon-button.tsx | 43 +++- src/components/ui/surface-elevation.ts | 30 +++ 5 files changed, 242 insertions(+), 63 deletions(-) create mode 100644 src/components/ui/surface-elevation.ts diff --git a/src/components/jobs/instructor-feed.tsx b/src/components/jobs/instructor-feed.tsx index 10721f9..a101a8e 100644 --- a/src/components/jobs/instructor-feed.tsx +++ b/src/components/jobs/instructor-feed.tsx @@ -402,13 +402,16 @@ export function InstructorFeed() { accessibilityLabel={t("jobsTab.instructorFeed.openArchive")} onPress={() => { if (isArchiveOpen) { + setIsArchiveOpen(false); archiveSheetRef.current?.close(); return; } + setIsArchiveOpen(true); archiveSheetRef.current?.expand(); }} tone={isArchiveOpen ? "primary" : "secondary"} size={58} + floating backgroundColorOverride={String(isArchiveOpen ? palette.primary : palette.surface)} icon={ diff --git a/src/components/jobs/instructor/instructor-jobs-archive-sheet.tsx b/src/components/jobs/instructor/instructor-jobs-archive-sheet.tsx index 3bc8043..0a0ce1c 100644 --- a/src/components/jobs/instructor/instructor-jobs-archive-sheet.tsx +++ b/src/components/jobs/instructor/instructor-jobs-archive-sheet.tsx @@ -3,19 +3,22 @@ import type React from "react"; import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Pressable, View } from "react-native"; -import { - InstructorJobCard, - type InstructorMarketplaceJob, -} from "@/components/jobs/instructor/instructor-job-card"; +import type { InstructorMarketplaceJob } from "@/components/jobs/instructor/instructor-job-card"; import { useCollapsedSheetHeight } from "@/components/layout/scroll-sheet-provider"; import { ThemedText } from "@/components/themed-text"; +import { ActionButton } from "@/components/ui/action-button"; import { AppSymbol } from "@/components/ui/app-symbol"; import { IconButton } from "@/components/ui/icon-button"; import { IconSymbol } from "@/components/ui/icon-symbol"; +import { getSurfaceElevationStyle } from "@/components/ui/surface-elevation"; import { type BrandPalette, BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; +import { getZoneLabel } from "@/constants/zones"; import type { Id } from "@/convex/_generated/dataModel"; import { toSportLabel } from "@/convex/constants"; import { + formatDateWithWeekday, + formatTime, + getApplicationStatusTranslationKey, getJobStatusToneWithReason, getJobStatusTranslationKey, type JobClosureReason, @@ -36,7 +39,6 @@ type InstructorJobsArchiveSheetProps = { palette: BrandPalette; locale: string; zoneLanguage: "en" | "he"; - now: number; onOpenStudio: (studioId: Id<"studioProfiles">, jobId: Id<"jobs">) => void; }; @@ -47,6 +49,51 @@ function formatArchiveDate(locale: string, timestamp: number) { }).format(timestamp); } +function formatArchivePay(locale: string, amount: number) { + return new Intl.NumberFormat(locale, { + style: "currency", + currency: "ILS", + maximumFractionDigits: 0, + }).format(amount); +} + +function buildArchiveOutcome( + row: InstructorArchiveRow, + t: ReturnType["t"], +): { + tone: "primary" | "success" | "gray" | "amber" | "muted"; + icon: "checkmark.circle.fill" | "xmark.circle.fill" | "clock.fill"; + label: string; +} { + if (row.applicationStatus === "rejected") { + return { + tone: "gray", + icon: "xmark.circle.fill", + label: t(getApplicationStatusTranslationKey(row.applicationStatus)), + }; + } + + if (row.applicationStatus === "withdrawn") { + return { + tone: "muted", + icon: "xmark.circle.fill", + label: t(getApplicationStatusTranslationKey(row.applicationStatus)), + }; + } + + const tone = getJobStatusToneWithReason(row.jobStatus, row.closureReason); + return { + tone, + icon: + tone === "success" + ? "checkmark.circle.fill" + : tone === "primary" + ? "clock.fill" + : "xmark.circle.fill", + label: t(getJobStatusTranslationKey(row.jobStatus, row.closureReason)), + }; +} + function getStatusTokens( tone: "primary" | "success" | "gray" | "amber" | "muted", palette: BrandPalette, @@ -77,10 +124,12 @@ function getStatusTokens( function ArchiveStatusChip({ label, + icon, palette, tone, }: { label: string; + icon: React.ComponentProps["name"]; palette: BrandPalette; tone: "primary" | "success" | "gray" | "amber" | "muted"; }) { @@ -89,6 +138,9 @@ function ArchiveStatusChip({ return ( + {label} @@ -103,6 +156,44 @@ function ArchiveStatusChip({ ); } +function ArchiveDetailRow({ + icon, + label, + value, + palette, +}: { + icon: React.ComponentProps["name"]; + label: string; + value: string; + palette: BrandPalette; +}) { + return ( + + + + + + + {label} + + + {value} + + + + ); +} + function ArchiveCompactRow({ expanded, locale, @@ -112,7 +203,6 @@ function ArchiveCompactRow({ row, t, zoneLanguage, - now, }: { expanded: boolean; locale: string; @@ -122,11 +212,16 @@ function ArchiveCompactRow({ row: InstructorArchiveRow; t: ReturnType["t"]; zoneLanguage: "en" | "he"; - now: number; }) { const sportLabel = useMemo(() => toSportLabel(row.sport as never), [row.sport]); - const statusTone = getJobStatusToneWithReason(row.jobStatus, row.closureReason); - const statusLabel = t(getJobStatusTranslationKey(row.jobStatus, row.closureReason)); + const archiveOutcome = useMemo(() => buildArchiveOutcome(row, t), [row, t]); + const zoneLabel = getZoneLabel(row.zone, zoneLanguage); + const scheduleLabel = `${formatArchiveDate(locale, row.startTime)} · ${formatTime( + row.startTime, + locale, + )}–${formatTime(row.endTime, locale)}`; + const payLabel = formatArchivePay(locale, row.pay); + const appliedLabel = formatArchiveDate(locale, row.appliedAt); return ( - - - {formatArchiveDate(locale, row.startTime)} - - - {t("jobsTab.instructorFeed.archiveAppliedOn", { - date: formatArchiveDate(locale, row.appliedAt), - })} - - - {row.studioName} + {`${row.studioName} · ${scheduleLabel}`} + + + + + + + + + {payLabel} + + + - - {expanded ? ( - + + + + + + onOpenStudio(row.studioId, row.jobId)} + palette={palette} + tone="secondary" + /> + ) : null} @@ -230,13 +360,12 @@ export function InstructorJobsArchiveSheet({ palette, locale, zoneLanguage, - now, onOpenStudio, }: InstructorJobsArchiveSheetProps) { const { t } = useTranslation(); const collapsedSheetHeight = useCollapsedSheetHeight(); const [expandedApplicationId, setExpandedApplicationId] = useState(null); - const snapPoints = ["78%"]; + const snapPoints = ["88%"]; const renderBackdrop = useCallback( (props: any) => ( @@ -272,9 +401,8 @@ export function InstructorJobsArchiveSheet({ backdropComponent={renderBackdrop} handleIndicatorStyle={{ backgroundColor: palette.borderStrong as string }} backgroundStyle={{ - backgroundColor: palette.surface as string, - borderTopWidth: BrandSpacing.xxs, - borderTopColor: palette.borderStrong as string, + backgroundColor: palette.surfaceElevated as string, + ...getSurfaceElevationStyle(palette, "sheet"), }} > )) )} diff --git a/src/components/map-tab/map-tab/map-mobile-stage.tsx b/src/components/map-tab/map-tab/map-mobile-stage.tsx index 5dbdb7b..666019e 100644 --- a/src/components/map-tab/map-tab/map-mobile-stage.tsx +++ b/src/components/map-tab/map-tab/map-mobile-stage.tsx @@ -77,6 +77,7 @@ export function MapMobileStage({ onPress={onEditToggle} tone={zoneModeActive ? "primary" : "primarySubtle"} size={58} + floating disabled={isSaving} backgroundColorOverride={ zoneModeActive ? (palette.primary as string) : (palette.surface as string) diff --git a/src/components/ui/icon-button.tsx b/src/components/ui/icon-button.tsx index 42ed3d7..9e0ef84 100644 --- a/src/components/ui/icon-button.tsx +++ b/src/components/ui/icon-button.tsx @@ -2,6 +2,7 @@ import { Pressable, View } from "react-native"; import { BrandRadius } from "@/constants/brand"; import { useBrand } from "@/hooks/use-brand"; +import { getSurfaceElevationStyle } from "./surface-elevation"; type IconButtonProps = { icon: React.ReactNode; @@ -11,6 +12,7 @@ type IconButtonProps = { size?: number; disabled?: boolean; backgroundColorOverride?: string; + floating?: boolean; }; export function IconButton({ @@ -21,19 +23,27 @@ export function IconButton({ size = 54, disabled = false, backgroundColorOverride, + floating = false, }: IconButtonProps) { const palette = useBrand(); + const raisedStyle = floating ? getSurfaceElevationStyle(palette, "floating") : undefined; const backgroundColor = backgroundColorOverride ?? (disabled - ? tone === "primary" || tone === "primarySubtle" - ? (palette.primarySubtle as string) - : (palette.surface as string) - : tone === "primary" - ? (palette.primary as string) - : tone === "primarySubtle" + ? tone === "primary" || tone === "primarySubtle" ? (palette.primarySubtle as string) - : (palette.surfaceAlt as string)); + : (palette.surface as string) + : tone === "primary" + ? (palette.primary as string) + : tone === "primarySubtle" + ? (palette.primarySubtle as string) + : (palette.surfaceAlt as string)); + const pressedBackgroundColor = + tone === "primary" + ? (palette.primaryPressed as string) + : tone === "primarySubtle" + ? (palette.surfaceElevated as string) + : (palette.surfaceElevated as string); return ( ({ - opacity: disabled ? 0.6 : pressed ? 0.9 : 1, - })} + style={({ pressed }) => [ + { + borderRadius: BrandRadius.buttonSubtle, + borderCurve: "continuous", + backgroundColor: disabled + ? backgroundColor + : pressed + ? pressedBackgroundColor + : backgroundColor, + ...(raisedStyle ?? {}), + }, + ]} > diff --git a/src/components/ui/surface-elevation.ts b/src/components/ui/surface-elevation.ts new file mode 100644 index 0000000..909bd1f --- /dev/null +++ b/src/components/ui/surface-elevation.ts @@ -0,0 +1,30 @@ +import { Platform, type ViewStyle } from "react-native"; + +import { type BrandPalette, BrandSpacing } from "@/constants/brand"; + +export function getSurfaceElevationStyle( + palette: BrandPalette, + tone: "sheet" | "floating", +): ViewStyle { + const shadowColor = + tone === "sheet" + ? (palette.onPrimaryShadowStrong as string) + : (palette.onPrimaryShadowSoft as string); + const elevation = tone === "sheet" ? BrandSpacing.md : BrandSpacing.sm; + const shadowRadius = tone === "sheet" ? BrandSpacing.lg : BrandSpacing.sm; + const shadowOffsetHeight = tone === "sheet" ? BrandSpacing.sm : BrandSpacing.xs; + + return Platform.select({ + ios: { + shadowColor, + shadowOpacity: 1, + shadowRadius, + shadowOffset: { width: 0, height: shadowOffsetHeight }, + }, + android: { + elevation, + shadowColor, + }, + default: {}, + }) as ViewStyle; +} From e7f130a6d0e0b7ae43858aa90b9e3485e004ebf7 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 24 Mar 2026 02:19:23 +0200 Subject: [PATCH 18/44] Adjust studio map pin visibility and shape --- src/components/maps/queue-map.native.tsx | 40 ++++++++++++------------ 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/components/maps/queue-map.native.tsx b/src/components/maps/queue-map.native.tsx index 091b1e4..f6712b2 100644 --- a/src/components/maps/queue-map.native.tsx +++ b/src/components/maps/queue-map.native.tsx @@ -43,9 +43,9 @@ const ATTRIBUTION_SIZE = BrandSpacing.iconContainer - BrandSpacing.xs; const ATTRIBUTION_ICON_SIZE = BrandSpacing.sm + BrandSpacing.xs; const LOADING_ICON_SIZE = BrandSpacing.iconContainer + BrandSpacing.sm; const LOADING_ICON_RADIUS = LOADING_ICON_SIZE / 2; -const STUDIO_MARKER_MIN_ZOOM = 12; -const STUDIO_MARKER_NEAR_ZOOM = 14; -const STUDIO_MARKER_CLOSE_ZOOM = 16; +const STUDIO_MARKER_MIN_ZOOM = 11; +const STUDIO_MARKER_NEAR_ZOOM = 13; +const STUDIO_MARKER_CLOSE_ZOOM = 15; const STUDIO_MARKER_BORDER_WIDTH = BrandSpacing.xxs; type MapLoadState = "loading" | "ready" | "error"; @@ -55,7 +55,6 @@ function getStudioMarkerMetrics(zoom: number) { if (zoom >= STUDIO_MARKER_CLOSE_ZOOM) { return { outerSize: BrandSpacing.controlLg, - imageSize: BrandSpacing.controlMd, tailSize: BrandSpacing.md, }; } @@ -63,14 +62,12 @@ function getStudioMarkerMetrics(zoom: number) { if (zoom >= STUDIO_MARKER_NEAR_ZOOM) { return { outerSize: BrandSpacing.avatarMd, - imageSize: BrandSpacing.controlSm, - tailSize: BrandSpacing.sm + BrandSpacing.xxs, + tailSize: BrandSpacing.md, }; } return { - outerSize: BrandSpacing.iconContainer + BrandSpacing.sm, - imageSize: BrandSpacing.iconContainer - BrandSpacing.xs, + outerSize: BrandSpacing.controlMd, tailSize: BrandSpacing.sm + BrandSpacing.xxs, }; } @@ -367,8 +364,8 @@ export const QueueMap = memo(function QueueMap({ {showStudioMarkers ? studios.map((studio) => { const markerSize = studioMarkerMetrics.outerSize; - const imageSize = studioMarkerMetrics.imageSize; const tailSize = studioMarkerMetrics.tailSize; + const tailOverlap = BrandSpacing.xs + BrandSpacing.xxs; const hasLogo = typeof studio.logoImageUrl === "string" && studio.logoImageUrl.length > 0; @@ -379,18 +376,18 @@ export const QueueMap = memo(function QueueMap({ anchor="bottom" lngLat={[studio.longitude, studio.latitude]} > - + @@ -404,28 +401,31 @@ export const QueueMap = memo(function QueueMap({ borderRadius: markerSize / 2, borderCurve: "continuous", borderWidth: STUDIO_MARKER_BORDER_WIDTH, - borderColor: palette.primary as string, + borderColor: palette.surfaceElevated as string, alignItems: "center", justifyContent: "center", overflow: "hidden", backgroundColor: pressed ? (palette.primarySubtle as string) - : (palette.surfaceElevated as string), + : (palette.secondary as string), })} > {hasLogo ? ( ) : ( - + {studio.studioName.slice(0, 1).toUpperCase()} )} From e855529d742622b2de885e57eef42f7d46fcf6f3 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 24 Mar 2026 02:22:27 +0200 Subject: [PATCH 19/44] Stabilize studio map pin anchoring --- src/components/maps/queue-map.native.tsx | 36 +++++++----------------- 1 file changed, 10 insertions(+), 26 deletions(-) diff --git a/src/components/maps/queue-map.native.tsx b/src/components/maps/queue-map.native.tsx index f6712b2..2fac0ad 100644 --- a/src/components/maps/queue-map.native.tsx +++ b/src/components/maps/queue-map.native.tsx @@ -43,32 +43,17 @@ const ATTRIBUTION_SIZE = BrandSpacing.iconContainer - BrandSpacing.xs; const ATTRIBUTION_ICON_SIZE = BrandSpacing.sm + BrandSpacing.xs; const LOADING_ICON_SIZE = BrandSpacing.iconContainer + BrandSpacing.sm; const LOADING_ICON_RADIUS = LOADING_ICON_SIZE / 2; -const STUDIO_MARKER_MIN_ZOOM = 11; -const STUDIO_MARKER_NEAR_ZOOM = 13; -const STUDIO_MARKER_CLOSE_ZOOM = 15; +const STUDIO_MARKER_MIN_ZOOM = 10; const STUDIO_MARKER_BORDER_WIDTH = BrandSpacing.xxs; type MapLoadState = "loading" | "ready" | "error"; const MAP_LOADING_OVERLAY_DELAY_MS = 180; function getStudioMarkerMetrics(zoom: number) { - if (zoom >= STUDIO_MARKER_CLOSE_ZOOM) { - return { - outerSize: BrandSpacing.controlLg, - tailSize: BrandSpacing.md, - }; - } - - if (zoom >= STUDIO_MARKER_NEAR_ZOOM) { - return { - outerSize: BrandSpacing.avatarMd, - tailSize: BrandSpacing.md, - }; - } - + void zoom; return { - outerSize: BrandSpacing.controlMd, - tailSize: BrandSpacing.sm + BrandSpacing.xxs, + outerSize: BrandSpacing.avatarMd, + tailSize: BrandSpacing.md, }; } @@ -366,6 +351,7 @@ export const QueueMap = memo(function QueueMap({ const markerSize = studioMarkerMetrics.outerSize; const tailSize = studioMarkerMetrics.tailSize; const tailOverlap = BrandSpacing.xs + BrandSpacing.xxs; + const markerAccent = palette.didit.accent as string; const hasLogo = typeof studio.logoImageUrl === "string" && studio.logoImageUrl.length > 0; @@ -385,9 +371,9 @@ export const QueueMap = memo(function QueueMap({ height: tailSize, borderLeftWidth: STUDIO_MARKER_BORDER_WIDTH, borderBottomWidth: STUDIO_MARKER_BORDER_WIDTH, - borderLeftColor: palette.surfaceElevated as string, - borderBottomColor: palette.surfaceElevated as string, - backgroundColor: palette.secondary as string, + borderLeftColor: markerAccent, + borderBottomColor: markerAccent, + backgroundColor: markerAccent, transform: [{ rotate: "45deg" }], }} /> @@ -401,13 +387,11 @@ export const QueueMap = memo(function QueueMap({ borderRadius: markerSize / 2, borderCurve: "continuous", borderWidth: STUDIO_MARKER_BORDER_WIDTH, - borderColor: palette.surfaceElevated as string, + borderColor: markerAccent, alignItems: "center", justifyContent: "center", overflow: "hidden", - backgroundColor: pressed - ? (palette.primarySubtle as string) - : (palette.secondary as string), + backgroundColor: pressed ? (palette.primarySubtle as string) : markerAccent, })} > {hasLogo ? ( From 0570e3c727842fd318195702027c5521ffea8927 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 24 Mar 2026 02:26:29 +0200 Subject: [PATCH 20/44] Use view annotations for studio map pins --- src/components/maps/queue-map.native.tsx | 29 ++++++++++++++++-------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/components/maps/queue-map.native.tsx b/src/components/maps/queue-map.native.tsx index 2fac0ad..7b7d293 100644 --- a/src/components/maps/queue-map.native.tsx +++ b/src/components/maps/queue-map.native.tsx @@ -4,7 +4,8 @@ import { Layer, Map as MapLibreMap, type MapRef, - Marker, + ViewAnnotation, + type ViewAnnotationRef, } from "@maplibre/maplibre-react-native"; import Constants from "expo-constants"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -109,6 +110,7 @@ export const QueueMap = memo(function QueueMap({ const mapKey = `${resolvedScheme}:${retryNonce}`; const mapRef = useRef(null); + const studioAnnotationRefs = useRef>({}); const mapLoadStateRef = useRef("loading"); const cameraRef = useRef<{ setStop: (config: unknown) => void; @@ -356,11 +358,17 @@ export const QueueMap = memo(function QueueMap({ typeof studio.logoImageUrl === "string" && studio.logoImageUrl.length > 0; return ( - { + studioAnnotationRefs.current[studio.studioId] = value; + }} + onSelected={() => { + onPressStudio?.(studio.studioId); + }} > - onPressStudio?.(studio.studioId)} - style={({ pressed }) => ({ + style={{ width: markerSize, height: markerSize, borderRadius: markerSize / 2, @@ -391,12 +399,15 @@ export const QueueMap = memo(function QueueMap({ alignItems: "center", justifyContent: "center", overflow: "hidden", - backgroundColor: pressed ? (palette.primarySubtle as string) : markerAccent, - })} + backgroundColor: markerAccent, + }} > {hasLogo ? ( { + studioAnnotationRefs.current[studio.studioId]?.refresh(); + }} style={{ width: markerSize, height: markerSize, @@ -413,9 +424,9 @@ export const QueueMap = memo(function QueueMap({ {studio.studioName.slice(0, 1).toUpperCase()} )} - + - + ); }) : null} From f4bb69b9b3d37b7a25c9de9225dd47e7ff4533c3 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 24 Mar 2026 02:28:08 +0200 Subject: [PATCH 21/44] Refine studio pin tail geometry --- src/components/maps/queue-map.native.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/components/maps/queue-map.native.tsx b/src/components/maps/queue-map.native.tsx index 7b7d293..5b04181 100644 --- a/src/components/maps/queue-map.native.tsx +++ b/src/components/maps/queue-map.native.tsx @@ -352,7 +352,9 @@ export const QueueMap = memo(function QueueMap({ ? studios.map((studio) => { const markerSize = studioMarkerMetrics.outerSize; const tailSize = studioMarkerMetrics.tailSize; - const tailOverlap = BrandSpacing.xs + BrandSpacing.xxs; + const tailOverlap = BrandSpacing.sm; + const tailBridgeWidth = BrandSpacing.sm; + const tailBridgeHeight = BrandSpacing.sm; const markerAccent = palette.didit.accent as string; const hasLogo = typeof studio.logoImageUrl === "string" && studio.logoImageUrl.length > 0; @@ -375,12 +377,18 @@ export const QueueMap = memo(function QueueMap({ style={{ position: "absolute", top: markerSize - tailOverlap, + width: tailBridgeWidth, + height: tailBridgeHeight, + borderRadius: BrandRadius.hard, + backgroundColor: markerAccent, + }} + /> + Date: Tue, 24 Mar 2026 02:32:28 +0200 Subject: [PATCH 22/44] Use SVG silhouette for studio map pins --- src/components/maps/queue-map.native.tsx | 166 ++++++++++++++--------- 1 file changed, 99 insertions(+), 67 deletions(-) diff --git a/src/components/maps/queue-map.native.tsx b/src/components/maps/queue-map.native.tsx index 5b04181..4f727cb 100644 --- a/src/components/maps/queue-map.native.tsx +++ b/src/components/maps/queue-map.native.tsx @@ -11,6 +11,7 @@ import Constants from "expo-constants"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ActivityIndicator, Pressable, StyleSheet, View } from "react-native"; +import Svg, { Path } from "react-native-svg"; import { APPLE_MAP_THEME } from "@/components/maps/queue-map-apple-theme"; import { QueueMapZonePolygons } from "@/components/maps/queue-map-zone-polygons"; @@ -45,7 +46,8 @@ const ATTRIBUTION_ICON_SIZE = BrandSpacing.sm + BrandSpacing.xs; const LOADING_ICON_SIZE = BrandSpacing.iconContainer + BrandSpacing.sm; const LOADING_ICON_RADIUS = LOADING_ICON_SIZE / 2; const STUDIO_MARKER_MIN_ZOOM = 10; -const STUDIO_MARKER_BORDER_WIDTH = BrandSpacing.xxs; +const STUDIO_PIN_PATH = + "M12 27.2C14.4 25.3 20.5 19.5 20.5 11.4C20.5 6.61 16.69 2.8 12 2.8C7.31 2.8 3.5 6.61 3.5 11.4C3.5 19.5 9.6 25.3 12 27.2Z"; type MapLoadState = "loading" | "ready" | "error"; const MAP_LOADING_OVERLAY_DELAY_MS = 180; @@ -53,11 +55,84 @@ const MAP_LOADING_OVERLAY_DELAY_MS = 180; function getStudioMarkerMetrics(zoom: number) { void zoom; return { - outerSize: BrandSpacing.avatarMd, - tailSize: BrandSpacing.md, + width: BrandSpacing.avatarMd, + height: BrandSpacing.avatarMd + BrandSpacing.lg, + imageSize: BrandSpacing.controlMd, + imageTop: BrandSpacing.xxs, }; } +function StudioMapPin({ + accentColor, + imageSize, + imageTop, + imageUrl, + label, + onImageLoad, + pinHeight, + pinWidth, + textColor, +}: { + accentColor: string; + imageSize: number; + imageTop: number; + imageUrl?: string; + label: string; + onImageLoad: () => void; + pinHeight: number; + pinWidth: number; + textColor: string; +}) { + const imageInset = (pinWidth - imageSize) / 2; + + return ( + + + + + {imageUrl ? ( + + ) : ( + + + {label} + + + )} + + ); +} + export const QueueMap = memo(function QueueMap({ mode, pin, @@ -350,11 +425,10 @@ export const QueueMap = memo(function QueueMap({ {showStudioMarkers ? studios.map((studio) => { - const markerSize = studioMarkerMetrics.outerSize; - const tailSize = studioMarkerMetrics.tailSize; - const tailOverlap = BrandSpacing.sm; - const tailBridgeWidth = BrandSpacing.sm; - const tailBridgeHeight = BrandSpacing.sm; + const markerWidth = studioMarkerMetrics.width; + const markerHeight = studioMarkerMetrics.height; + const imageSize = studioMarkerMetrics.imageSize; + const imageTop = studioMarkerMetrics.imageTop; const markerAccent = palette.didit.accent as string; const hasLogo = typeof studio.logoImageUrl === "string" && studio.logoImageUrl.length > 0; @@ -372,67 +446,25 @@ export const QueueMap = memo(function QueueMap({ onPressStudio?.(studio.studioId); }} > - - - + { + studioAnnotationRefs.current[studio.studioId]?.refresh(); }} + pinHeight={markerHeight} + pinWidth={markerWidth} + textColor={palette.onPrimary as string} /> - - {hasLogo ? ( - { - studioAnnotationRefs.current[studio.studioId]?.refresh(); - }} - style={{ - width: markerSize, - height: markerSize, - borderRadius: markerSize / 2, - borderCurve: "continuous", - }} - contentFit="cover" - /> - ) : ( - - {studio.studioName.slice(0, 1).toUpperCase()} - - )} - ); From f8a416bc55b23e96b9931f080fd5eef60f78d165 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 24 Mar 2026 02:33:58 +0200 Subject: [PATCH 23/44] Layer studio pins above zones and frame images --- src/components/maps/queue-map.native.tsx | 49 ++++++++++-------------- 1 file changed, 20 insertions(+), 29 deletions(-) diff --git a/src/components/maps/queue-map.native.tsx b/src/components/maps/queue-map.native.tsx index 4f727cb..679615f 100644 --- a/src/components/maps/queue-map.native.tsx +++ b/src/components/maps/queue-map.native.tsx @@ -4,8 +4,7 @@ import { Layer, Map as MapLibreMap, type MapRef, - ViewAnnotation, - type ViewAnnotationRef, + Marker, } from "@maplibre/maplibre-react-native"; import Constants from "expo-constants"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -57,8 +56,8 @@ function getStudioMarkerMetrics(zoom: number) { return { width: BrandSpacing.avatarMd, height: BrandSpacing.avatarMd + BrandSpacing.lg, - imageSize: BrandSpacing.controlMd, - imageTop: BrandSpacing.xxs, + imageSize: BrandSpacing.controlSm, + imageTop: BrandSpacing.xs, }; } @@ -68,7 +67,6 @@ function StudioMapPin({ imageTop, imageUrl, label, - onImageLoad, pinHeight, pinWidth, textColor, @@ -78,7 +76,6 @@ function StudioMapPin({ imageTop: number; imageUrl?: string; label: string; - onImageLoad: () => void; pinHeight: number; pinWidth: number; textColor: string; @@ -98,7 +95,6 @@ function StudioMapPin({ {imageUrl ? ( (null); - const studioAnnotationRefs = useRef>({}); const mapLoadStateRef = useRef("loading"); const cameraRef = useRef<{ setStop: (config: unknown) => void; @@ -434,17 +429,11 @@ export const QueueMap = memo(function QueueMap({ typeof studio.logoImageUrl === "string" && studio.logoImageUrl.length > 0; return ( - { - studioAnnotationRefs.current[studio.studioId] = value; - }} - onSelected={() => { - onPressStudio?.(studio.studioId); - }} > - { - studioAnnotationRefs.current[studio.studioId]?.refresh(); - }} - pinHeight={markerHeight} - pinWidth={markerWidth} - textColor={palette.onPrimary as string} - /> + onPressStudio?.(studio.studioId)} + style={{ width: markerWidth, height: markerHeight }} + > + + - + ); }) : null} From 6fdc2f67926cb374cd66cb1967cc90d538fb750a Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 24 Mar 2026 02:35:15 +0200 Subject: [PATCH 24/44] Mask studio photos inside map pins --- src/components/maps/queue-map.native.tsx | 61 +++++++++++------------- 1 file changed, 29 insertions(+), 32 deletions(-) diff --git a/src/components/maps/queue-map.native.tsx b/src/components/maps/queue-map.native.tsx index 679615f..1c100d4 100644 --- a/src/components/maps/queue-map.native.tsx +++ b/src/components/maps/queue-map.native.tsx @@ -56,8 +56,8 @@ function getStudioMarkerMetrics(zoom: number) { return { width: BrandSpacing.avatarMd, height: BrandSpacing.avatarMd + BrandSpacing.lg, - imageSize: BrandSpacing.controlSm, - imageTop: BrandSpacing.xs, + imageSize: BrandSpacing.iconContainer - BrandSpacing.xs, + imageTop: BrandSpacing.sm - BrandSpacing.xxs, }; } @@ -92,39 +92,36 @@ function StudioMapPin({ > - {imageUrl ? ( - - ) : ( - + + {imageUrl ? ( + + ) : ( {label} - - )} + )} + ); } From 547f1095503cdacc846f1598e85e3e0ef720f05e Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 24 Mar 2026 02:36:31 +0200 Subject: [PATCH 25/44] Restore stable studio pin annotation anchoring --- src/components/maps/queue-map.native.tsx | 34 +++++++++++------------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/src/components/maps/queue-map.native.tsx b/src/components/maps/queue-map.native.tsx index 1c100d4..b9aed9d 100644 --- a/src/components/maps/queue-map.native.tsx +++ b/src/components/maps/queue-map.native.tsx @@ -4,7 +4,7 @@ import { Layer, Map as MapLibreMap, type MapRef, - Marker, + ViewAnnotation, } from "@maplibre/maplibre-react-native"; import Constants from "expo-constants"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -426,11 +426,14 @@ export const QueueMap = memo(function QueueMap({ typeof studio.logoImageUrl === "string" && studio.logoImageUrl.length > 0; return ( - { + onPressStudio?.(studio.studioId); + }} > - onPressStudio?.(studio.studioId)} - style={{ width: markerWidth, height: markerHeight }} - > - - + - + ); }) : null} From 5323ddb751b2052dcc0ebf44c4eddc1f4496f03d Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 24 Mar 2026 03:11:08 +0200 Subject: [PATCH 26/44] Refine studio pin opening and zone outlines --- .../maps/queue-map-zone-polygons.tsx | 8 +- src/components/maps/queue-map.native.tsx | 99 +++++++++++-------- 2 files changed, 62 insertions(+), 45 deletions(-) diff --git a/src/components/maps/queue-map-zone-polygons.tsx b/src/components/maps/queue-map-zone-polygons.tsx index e050187..be0cc07 100644 --- a/src/components/maps/queue-map-zone-polygons.tsx +++ b/src/components/maps/queue-map-zone-polygons.tsx @@ -46,9 +46,11 @@ export const QueueMapZonePolygons = memo(function QueueMapZonePolygons({ ? Math.max(APPLE_MAP_THEME.overlay.touchFillOpacity, 0.1) : 0; const previewOutlineOpacity = showAllZones - ? Math.max(APPLE_MAP_THEME.overlay.baseOutlineOpacity, 0.88) + ? Math.max(APPLE_MAP_THEME.overlay.baseOutlineOpacity, 0.72) : 0; const allZonesLabelOpacity = showAllZones && showLabelLayers ? 0.78 : 0; + const selectedOutlineOpacity = Math.min(APPLE_MAP_THEME.overlay.selectionOutlineOpacity, 0.82); + const selectedOutlineWidth = Math.max(APPLE_MAP_THEME.overlay.selectionOutlineWidth - 0.35, 1.4); return ( diff --git a/src/components/maps/queue-map.native.tsx b/src/components/maps/queue-map.native.tsx index b9aed9d..2c9f939 100644 --- a/src/components/maps/queue-map.native.tsx +++ b/src/components/maps/queue-map.native.tsx @@ -10,7 +10,7 @@ import Constants from "expo-constants"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ActivityIndicator, Pressable, StyleSheet, View } from "react-native"; -import Svg, { Path } from "react-native-svg"; +import Svg, { Circle, ClipPath, Defs, Path, Image as SvgImage } from "react-native-svg"; import { APPLE_MAP_THEME } from "@/components/maps/queue-map-apple-theme"; import { QueueMapZonePolygons } from "@/components/maps/queue-map-zone-polygons"; @@ -19,7 +19,6 @@ import { BrandRadius, BrandSpacing, getMapBrandPalette } from "@/constants/brand import { getZoneIndexEntry, ISRAEL_MAP_INTERACTION_BOUNDS } from "@/constants/zones-map"; import { useBrand } from "@/hooks/use-brand"; import { useThemePreference } from "@/hooks/use-theme-preference"; -import { Image } from "@/tw/image"; import { ActionButton } from "../ui/action-button"; import { IconSymbol } from "../ui/icon-symbol"; import { KitSurface } from "../ui/kit"; @@ -45,8 +44,19 @@ const ATTRIBUTION_ICON_SIZE = BrandSpacing.sm + BrandSpacing.xs; const LOADING_ICON_SIZE = BrandSpacing.iconContainer + BrandSpacing.sm; const LOADING_ICON_RADIUS = LOADING_ICON_SIZE / 2; const STUDIO_MARKER_MIN_ZOOM = 10; +const STUDIO_PIN_VIEWBOX = { + x: 1098.489, + y: 1430.882, + width: 2351.972, + height: 3656.301, +} as const; const STUDIO_PIN_PATH = - "M12 27.2C14.4 25.3 20.5 19.5 20.5 11.4C20.5 6.61 16.69 2.8 12 2.8C7.31 2.8 3.5 6.61 3.5 11.4C3.5 19.5 9.6 25.3 12 27.2Z"; + "M1098.489,2606.868C1098.489,1957.824 1625.431,1430.882 2274.475,1430.882C2923.519,1430.882 3450.461,1957.824 3450.461,2606.868C3450.461,3456.164 3031.901,4240.917 2274.475,5087.183C1517.049,4240.917 1098.489,3456.164 1098.489,2606.868Z"; +const STUDIO_PIN_HOLE = { + cx: 2274.475, + cy: 2606.868, + radius: 1036.897, +} as const; type MapLoadState = "loading" | "ready" | "error"; const MAP_LOADING_OVERLAY_DELAY_MS = 180; @@ -56,15 +66,11 @@ function getStudioMarkerMetrics(zoom: number) { return { width: BrandSpacing.avatarMd, height: BrandSpacing.avatarMd + BrandSpacing.lg, - imageSize: BrandSpacing.iconContainer - BrandSpacing.xs, - imageTop: BrandSpacing.sm - BrandSpacing.xxs, }; } function StudioMapPin({ accentColor, - imageSize, - imageTop, imageUrl, label, pinHeight, @@ -72,56 +78,69 @@ function StudioMapPin({ textColor, }: { accentColor: string; - imageSize: number; - imageTop: number; imageUrl?: string; label: string; pinHeight: number; pinWidth: number; textColor: string; }) { - const imageInset = (pinWidth - imageSize) / 2; + const clipRadius = STUDIO_PIN_HOLE.radius * 0.92; + const clipCenterY = STUDIO_PIN_HOLE.cy + STUDIO_PIN_HOLE.radius * 0.07; + const fallbackSize = (clipRadius * 2 * pinWidth) / STUDIO_PIN_VIEWBOX.width; + const fallbackLeft = + ((STUDIO_PIN_HOLE.cx - clipRadius - STUDIO_PIN_VIEWBOX.x) * pinWidth) / + STUDIO_PIN_VIEWBOX.width; + const fallbackTop = + ((clipCenterY - clipRadius - STUDIO_PIN_VIEWBOX.y) * pinHeight) / STUDIO_PIN_VIEWBOX.height; + const clipId = `studio-pin-hole-${label}`; return ( - - {imageUrl ? ( - - ) : ( + <> + + + + + + + + ) : null} + + {!imageUrl ? ( + {label} - )} - + + ) : null} ); } @@ -419,8 +438,6 @@ export const QueueMap = memo(function QueueMap({ ? studios.map((studio) => { const markerWidth = studioMarkerMetrics.width; const markerHeight = studioMarkerMetrics.height; - const imageSize = studioMarkerMetrics.imageSize; - const imageTop = studioMarkerMetrics.imageTop; const markerAccent = palette.didit.accent as string; const hasLogo = typeof studio.logoImageUrl === "string" && studio.logoImageUrl.length > 0; @@ -443,8 +460,6 @@ export const QueueMap = memo(function QueueMap({ > Date: Tue, 24 Mar 2026 03:16:05 +0200 Subject: [PATCH 27/44] Polish studio pin layering and zoom scaling --- src/components/maps/queue-map.native.tsx | 56 +++++++++++++++++++----- 1 file changed, 44 insertions(+), 12 deletions(-) diff --git a/src/components/maps/queue-map.native.tsx b/src/components/maps/queue-map.native.tsx index 2c9f939..fdd4fc2 100644 --- a/src/components/maps/queue-map.native.tsx +++ b/src/components/maps/queue-map.native.tsx @@ -57,6 +57,7 @@ const STUDIO_PIN_HOLE = { cy: 2606.868, radius: 1036.897, } as const; +const STUDIO_PIN_OUTLINE_WIDTH = 168; type MapLoadState = "loading" | "ready" | "error"; const MAP_LOADING_OVERLAY_DELAY_MS = 180; @@ -64,17 +65,24 @@ const MAP_LOADING_OVERLAY_DELAY_MS = 180; function getStudioMarkerMetrics(zoom: number) { void zoom; return { - width: BrandSpacing.avatarMd, - height: BrandSpacing.avatarMd + BrandSpacing.lg, + width: BrandSpacing.avatarLg, + height: BrandSpacing.avatarLg + BrandSpacing.xl, }; } +function getStudioPinScale(zoom: number) { + if (zoom <= STUDIO_MARKER_MIN_ZOOM) return 1; + if (zoom >= 15.5) return 1.18; + return 1 + ((zoom - STUDIO_MARKER_MIN_ZOOM) / (15.5 - STUDIO_MARKER_MIN_ZOOM)) * 0.18; +} + function StudioMapPin({ accentColor, imageUrl, label, pinHeight, pinWidth, + strokeColor, textColor, }: { accentColor: string; @@ -82,6 +90,7 @@ function StudioMapPin({ label: string; pinHeight: number; pinWidth: number; + strokeColor: string; textColor: string; }) { const clipRadius = STUDIO_PIN_HOLE.radius * 0.92; @@ -102,7 +111,13 @@ function StudioMapPin({ viewBox={`${STUDIO_PIN_VIEWBOX.x} ${STUDIO_PIN_VIEWBOX.y} ${STUDIO_PIN_VIEWBOX.width} ${STUDIO_PIN_VIEWBOX.height}`} style={{ position: "absolute", top: 0, left: 0 }} > - + {imageUrl ? ( <> @@ -208,6 +223,7 @@ export const QueueMap = memo(function QueueMap({ ); const pinShape = useMemo(() => createPinShape(pin), [pin]); const studioMarkerMetrics = useMemo(() => getStudioMarkerMetrics(currentZoom), [currentZoom]); + const studioPinScale = useMemo(() => getStudioPinScale(currentZoom), [currentZoom]); const showStudioMarkers = studios.length > 0 && currentZoom >= STUDIO_MARKER_MIN_ZOOM; const handleRetry = useCallback(() => { setBaseMapStyle(null); @@ -439,6 +455,7 @@ export const QueueMap = memo(function QueueMap({ const markerWidth = studioMarkerMetrics.width; const markerHeight = studioMarkerMetrics.height; const markerAccent = palette.didit.accent as string; + const markerStroke = mapPalette.styleBackground; const hasLogo = typeof studio.logoImageUrl === "string" && studio.logoImageUrl.length > 0; @@ -456,16 +473,31 @@ export const QueueMap = memo(function QueueMap({ accessible accessibilityRole="button" accessibilityLabel={studio.studioName} - style={{ width: markerWidth, height: markerHeight }} + style={{ width: markerWidth, height: markerHeight, overflow: "visible" }} > - + + + ); From 2e045f8c1ac9beb63298a174099a7ff68ff1eac1 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 24 Mar 2026 03:18:43 +0200 Subject: [PATCH 28/44] Fix studio pin photo mask and zoom sizing --- src/components/maps/queue-map.native.tsx | 25 ++++++++---------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/src/components/maps/queue-map.native.tsx b/src/components/maps/queue-map.native.tsx index fdd4fc2..83d7d3e 100644 --- a/src/components/maps/queue-map.native.tsx +++ b/src/components/maps/queue-map.native.tsx @@ -57,7 +57,6 @@ const STUDIO_PIN_HOLE = { cy: 2606.868, radius: 1036.897, } as const; -const STUDIO_PIN_OUTLINE_WIDTH = 168; type MapLoadState = "loading" | "ready" | "error"; const MAP_LOADING_OVERLAY_DELAY_MS = 180; @@ -65,15 +64,15 @@ const MAP_LOADING_OVERLAY_DELAY_MS = 180; function getStudioMarkerMetrics(zoom: number) { void zoom; return { - width: BrandSpacing.avatarLg, - height: BrandSpacing.avatarLg + BrandSpacing.xl, + width: BrandSpacing.avatarMd, + height: BrandSpacing.avatarMd + BrandSpacing.lg, }; } function getStudioPinScale(zoom: number) { - if (zoom <= STUDIO_MARKER_MIN_ZOOM) return 1; - if (zoom >= 15.5) return 1.18; - return 1 + ((zoom - STUDIO_MARKER_MIN_ZOOM) / (15.5 - STUDIO_MARKER_MIN_ZOOM)) * 0.18; + if (zoom <= 13) return 1; + if (zoom >= 15.75) return 1.1; + return 1 + ((zoom - 13) / (15.75 - 13)) * 0.1; } function StudioMapPin({ @@ -82,7 +81,6 @@ function StudioMapPin({ label, pinHeight, pinWidth, - strokeColor, textColor, }: { accentColor: string; @@ -90,7 +88,6 @@ function StudioMapPin({ label: string; pinHeight: number; pinWidth: number; - strokeColor: string; textColor: string; }) { const clipRadius = STUDIO_PIN_HOLE.radius * 0.92; @@ -111,13 +108,7 @@ function StudioMapPin({ viewBox={`${STUDIO_PIN_VIEWBOX.x} ${STUDIO_PIN_VIEWBOX.y} ${STUDIO_PIN_VIEWBOX.width} ${STUDIO_PIN_VIEWBOX.height}`} style={{ position: "absolute", top: 0, left: 0 }} > - + {imageUrl ? ( <> @@ -125,6 +116,7 @@ function StudioMapPin({ + 0; @@ -465,6 +456,7 @@ export const QueueMap = memo(function QueueMap({ id={`studio-marker:${studio.studioId}`} anchor="bottom" lngLat={[studio.longitude, studio.latitude]} + style={{ zIndex: 100, elevation: 100 }} onSelected={() => { onPressStudio?.(studio.studioId); }} @@ -494,7 +486,6 @@ export const QueueMap = memo(function QueueMap({ label={studio.studioName.slice(0, 1).toUpperCase()} pinHeight={markerHeight} pinWidth={markerWidth} - strokeColor={markerStroke} textColor={palette.onPrimary as string} /> From a48e2900b2df11ce70bcbcf1d487cb72f0cb4b9d Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 24 Mar 2026 03:21:30 +0200 Subject: [PATCH 29/44] Fix studio map pins and retune marker accent --- src/components/maps/queue-map.native.tsx | 110 ++++++++++------------- src/constants/brand.ts | 2 + 2 files changed, 51 insertions(+), 61 deletions(-) diff --git a/src/components/maps/queue-map.native.tsx b/src/components/maps/queue-map.native.tsx index 83d7d3e..6485783 100644 --- a/src/components/maps/queue-map.native.tsx +++ b/src/components/maps/queue-map.native.tsx @@ -4,13 +4,13 @@ import { Layer, Map as MapLibreMap, type MapRef, - ViewAnnotation, + Marker, } from "@maplibre/maplibre-react-native"; import Constants from "expo-constants"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ActivityIndicator, Pressable, StyleSheet, View } from "react-native"; -import Svg, { Circle, ClipPath, Defs, Path, Image as SvgImage } from "react-native-svg"; +import Svg, { Path } from "react-native-svg"; import { APPLE_MAP_THEME } from "@/components/maps/queue-map-apple-theme"; import { QueueMapZonePolygons } from "@/components/maps/queue-map-zone-polygons"; @@ -19,6 +19,7 @@ import { BrandRadius, BrandSpacing, getMapBrandPalette } from "@/constants/brand import { getZoneIndexEntry, ISRAEL_MAP_INTERACTION_BOUNDS } from "@/constants/zones-map"; import { useBrand } from "@/hooks/use-brand"; import { useThemePreference } from "@/hooks/use-theme-preference"; +import { Image } from "@/tw/image"; import { ActionButton } from "../ui/action-button"; import { IconSymbol } from "../ui/icon-symbol"; import { KitSurface } from "../ui/kit"; @@ -52,11 +53,6 @@ const STUDIO_PIN_VIEWBOX = { } as const; const STUDIO_PIN_PATH = "M1098.489,2606.868C1098.489,1957.824 1625.431,1430.882 2274.475,1430.882C2923.519,1430.882 3450.461,1957.824 3450.461,2606.868C3450.461,3456.164 3031.901,4240.917 2274.475,5087.183C1517.049,4240.917 1098.489,3456.164 1098.489,2606.868Z"; -const STUDIO_PIN_HOLE = { - cx: 2274.475, - cy: 2606.868, - radius: 1036.897, -} as const; type MapLoadState = "loading" | "ready" | "error"; const MAP_LOADING_OVERLAY_DELAY_MS = 180; @@ -79,6 +75,8 @@ function StudioMapPin({ accentColor, imageUrl, label, + imageSize, + imageTop, pinHeight, pinWidth, textColor, @@ -86,19 +84,13 @@ function StudioMapPin({ accentColor: string; imageUrl?: string; label: string; + imageSize: number; + imageTop: number; pinHeight: number; pinWidth: number; textColor: string; }) { - const clipRadius = STUDIO_PIN_HOLE.radius * 0.92; - const clipCenterY = STUDIO_PIN_HOLE.cy + STUDIO_PIN_HOLE.radius * 0.07; - const fallbackSize = (clipRadius * 2 * pinWidth) / STUDIO_PIN_VIEWBOX.width; - const fallbackLeft = - ((STUDIO_PIN_HOLE.cx - clipRadius - STUDIO_PIN_VIEWBOX.x) * pinWidth) / - STUDIO_PIN_VIEWBOX.width; - const fallbackTop = - ((clipCenterY - clipRadius - STUDIO_PIN_VIEWBOX.y) * pinHeight) / STUDIO_PIN_VIEWBOX.height; - const clipId = `studio-pin-hole-${label}`; + const imageInset = (pinWidth - imageSize) / 2; return ( @@ -109,45 +101,39 @@ function StudioMapPin({ style={{ position: "absolute", top: 0, left: 0 }} > - {imageUrl ? ( - <> - - - - - - - - - ) : null} - {!imageUrl ? ( - + + {imageUrl ? ( + + ) : ( {label} - - ) : null} + )} + ); } @@ -446,29 +432,29 @@ export const QueueMap = memo(function QueueMap({ ? studios.map((studio) => { const markerWidth = studioMarkerMetrics.width; const markerHeight = studioMarkerMetrics.height; - const markerAccent = palette.didit.accent as string; + const markerAccent = mapPalette.markerAccent; const hasLogo = typeof studio.logoImageUrl === "string" && studio.logoImageUrl.length > 0; + const markerImageSize = markerWidth * 0.68; + const markerImageTop = markerHeight * 0.12; return ( - { - onPressStudio?.(studio.studioId); - }} > - { + onPressStudio?.(studio.studioId); + }} style={{ width: markerWidth, height: markerHeight, overflow: "visible" }} > - - + + ); }) : null} diff --git a/src/constants/brand.ts b/src/constants/brand.ts index 1c54d42..698fedb 100644 --- a/src/constants/brand.ts +++ b/src/constants/brand.ts @@ -177,6 +177,7 @@ const NativeMapBrandPalette = { selectedOutlineOpacity: 1.0, surfaceAlt: "#F7FBF4", primary: "#7C3AED", // Vibrant purple + markerAccent: "#19B5FF", text: "#182018", textHalo: "#F7FBF4", }, @@ -197,6 +198,7 @@ const NativeMapBrandPalette = { selectedOutlineOpacity: 1.0, surfaceAlt: "#141C17", primary: "#A78BFA", // Vibrant light purple + markerAccent: "#5FD6FF", text: "#EEF3EA", textHalo: "#0E1412", }, From 78e10914fdbd8228ba8cba5fdb6bf76a7ed7632b Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 24 Mar 2026 03:23:34 +0200 Subject: [PATCH 30/44] Stabilize studio pins on annotation path --- src/components/maps/queue-map.native.tsx | 26 ++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/components/maps/queue-map.native.tsx b/src/components/maps/queue-map.native.tsx index 6485783..10fb499 100644 --- a/src/components/maps/queue-map.native.tsx +++ b/src/components/maps/queue-map.native.tsx @@ -4,7 +4,8 @@ import { Layer, Map as MapLibreMap, type MapRef, - Marker, + ViewAnnotation, + type ViewAnnotationRef, } from "@maplibre/maplibre-react-native"; import Constants from "expo-constants"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -66,9 +67,8 @@ function getStudioMarkerMetrics(zoom: number) { } function getStudioPinScale(zoom: number) { - if (zoom <= 13) return 1; - if (zoom >= 15.75) return 1.1; - return 1 + ((zoom - 13) / (15.75 - 13)) * 0.1; + void zoom; + return 1; } function StudioMapPin({ @@ -77,6 +77,7 @@ function StudioMapPin({ label, imageSize, imageTop, + onImageLoad, pinHeight, pinWidth, textColor, @@ -86,6 +87,7 @@ function StudioMapPin({ label: string; imageSize: number; imageTop: number; + onImageLoad?: () => void; pinHeight: number; pinWidth: number; textColor: string; @@ -120,6 +122,7 @@ function StudioMapPin({ {imageUrl ? ( (null); + const studioAnnotationRefs = useRef>({}); const mapLoadStateRef = useRef("loading"); const cameraRef = useRef<{ setStop: (config: unknown) => void; @@ -439,11 +443,14 @@ export const QueueMap = memo(function QueueMap({ const markerImageTop = markerHeight * 0.12; return ( - { + studioAnnotationRefs.current[studio.studioId] = value; + }} > { + studioAnnotationRefs.current[studio.studioId]?.refresh(); + }, + } + : {})} pinHeight={markerHeight} pinWidth={markerWidth} textColor={palette.onPrimary as string} /> - + ); }) : null} From c505f5d8ae1433817439456cef2e00206cfd1266 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 24 Mar 2026 03:29:18 +0200 Subject: [PATCH 31/44] Render studio pins with symbol layer --- src/components/maps/queue-map.native.tsx | 237 +++++++++-------------- 1 file changed, 87 insertions(+), 150 deletions(-) diff --git a/src/components/maps/queue-map.native.tsx b/src/components/maps/queue-map.native.tsx index 10fb499..48d6e31 100644 --- a/src/components/maps/queue-map.native.tsx +++ b/src/components/maps/queue-map.native.tsx @@ -1,17 +1,15 @@ import { Camera, GeoJSONSource, + Images, Layer, Map as MapLibreMap, type MapRef, - ViewAnnotation, - type ViewAnnotationRef, } from "@maplibre/maplibre-react-native"; import Constants from "expo-constants"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ActivityIndicator, Pressable, StyleSheet, View } from "react-native"; -import Svg, { Path } from "react-native-svg"; import { APPLE_MAP_THEME } from "@/components/maps/queue-map-apple-theme"; import { QueueMapZonePolygons } from "@/components/maps/queue-map-zone-polygons"; @@ -20,7 +18,6 @@ import { BrandRadius, BrandSpacing, getMapBrandPalette } from "@/constants/brand import { getZoneIndexEntry, ISRAEL_MAP_INTERACTION_BOUNDS } from "@/constants/zones-map"; import { useBrand } from "@/hooks/use-brand"; import { useThemePreference } from "@/hooks/use-theme-preference"; -import { Image } from "@/tw/image"; import { ActionButton } from "../ui/action-button"; import { IconSymbol } from "../ui/icon-symbol"; import { KitSurface } from "../ui/kit"; @@ -46,99 +43,36 @@ const ATTRIBUTION_ICON_SIZE = BrandSpacing.sm + BrandSpacing.xs; const LOADING_ICON_SIZE = BrandSpacing.iconContainer + BrandSpacing.sm; const LOADING_ICON_RADIUS = LOADING_ICON_SIZE / 2; const STUDIO_MARKER_MIN_ZOOM = 10; -const STUDIO_PIN_VIEWBOX = { - x: 1098.489, - y: 1430.882, - width: 2351.972, - height: 3656.301, -} as const; -const STUDIO_PIN_PATH = - "M1098.489,2606.868C1098.489,1957.824 1625.431,1430.882 2274.475,1430.882C2923.519,1430.882 3450.461,1957.824 3450.461,2606.868C3450.461,3456.164 3031.901,4240.917 2274.475,5087.183C1517.049,4240.917 1098.489,3456.164 1098.489,2606.868Z"; +const STUDIO_PIN_ICON_KEY_PREFIX = "studio-pin:"; type MapLoadState = "loading" | "ready" | "error"; const MAP_LOADING_OVERLAY_DELAY_MS = 180; -function getStudioMarkerMetrics(zoom: number) { - void zoom; - return { - width: BrandSpacing.avatarMd, - height: BrandSpacing.avatarMd + BrandSpacing.lg, - }; -} - -function getStudioPinScale(zoom: number) { - void zoom; - return 1; -} - -function StudioMapPin({ +function buildStudioPinDataUri({ accentColor, imageUrl, label, - imageSize, - imageTop, - onImageLoad, - pinHeight, - pinWidth, textColor, }: { accentColor: string; imageUrl?: string; label: string; - imageSize: number; - imageTop: number; - onImageLoad?: () => void; - pinHeight: number; - pinWidth: number; textColor: string; }) { - const imageInset = (pinWidth - imageSize) / 2; - - return ( - - - - - - {imageUrl ? ( - - ) : ( - - {label} - - )} - - - ); + const safeImageUrl = imageUrl ? imageUrl.replaceAll("&", "&") : null; + const safeLabel = label.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); + const svg = ` + + + + ${ + safeImageUrl + ? `` + : `${safeLabel}` + } + + `.trim(); + return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; } export const QueueMap = memo(function QueueMap({ @@ -193,7 +127,6 @@ export const QueueMap = memo(function QueueMap({ const mapKey = `${resolvedScheme}:${retryNonce}`; const mapRef = useRef(null); - const studioAnnotationRefs = useRef>({}); const mapLoadStateRef = useRef("loading"); const cameraRef = useRef<{ setStop: (config: unknown) => void; @@ -204,9 +137,39 @@ export const QueueMap = memo(function QueueMap({ [selectedZoneIds, zoneIdProperty], ); const pinShape = useMemo(() => createPinShape(pin), [pin]); - const studioMarkerMetrics = useMemo(() => getStudioMarkerMetrics(currentZoom), [currentZoom]); - const studioPinScale = useMemo(() => getStudioPinScale(currentZoom), [currentZoom]); const showStudioMarkers = studios.length > 0 && currentZoom >= STUDIO_MARKER_MIN_ZOOM; + const studioMarkerImages = useMemo( + () => + Object.fromEntries( + studios.map((studio) => [ + `${STUDIO_PIN_ICON_KEY_PREFIX}${studio.studioId}`, + buildStudioPinDataUri({ + accentColor: mapPalette.markerAccent, + ...(studio.logoImageUrl ? { imageUrl: studio.logoImageUrl } : {}), + label: studio.studioName.slice(0, 1).toUpperCase(), + textColor: palette.onPrimary as string, + }), + ]), + ), + [mapPalette.markerAccent, palette.onPrimary, studios], + ); + const studioMarkerSource = useMemo( + () => ({ + type: "FeatureCollection", + features: studios.map((studio) => ({ + type: "Feature", + geometry: { + type: "Point", + coordinates: [studio.longitude, studio.latitude], + }, + properties: { + studioId: studio.studioId, + iconKey: `${STUDIO_PIN_ICON_KEY_PREFIX}${studio.studioId}`, + }, + })), + }), + [studios], + ); const handleRetry = useCallback(() => { setBaseMapStyle(null); setMapErrorMessage(null); @@ -432,70 +395,44 @@ export const QueueMap = memo(function QueueMap({ onPressZone={onPressZone} /> - {showStudioMarkers - ? studios.map((studio) => { - const markerWidth = studioMarkerMetrics.width; - const markerHeight = studioMarkerMetrics.height; - const markerAccent = mapPalette.markerAccent; - const hasLogo = - typeof studio.logoImageUrl === "string" && studio.logoImageUrl.length > 0; - const markerImageSize = markerWidth * 0.68; - const markerImageTop = markerHeight * 0.12; - - return ( - { - studioAnnotationRefs.current[studio.studioId] = value; - }} - > - { - onPressStudio?.(studio.studioId); - }} - style={{ width: markerWidth, height: markerHeight, overflow: "visible" }} - > - - { - studioAnnotationRefs.current[studio.studioId]?.refresh(); - }, - } - : {})} - pinHeight={markerHeight} - pinWidth={markerWidth} - textColor={palette.onPrimary as string} - /> - - - - ); - }) - : null} + {showStudioMarkers ? : null} + {showStudioMarkers ? ( + { + const native = event?.nativeEvent ?? event; + const studioId = native?.features?.[0]?.properties?.studioId; + if (typeof studioId === "string") { + onPressStudio?.(studioId); + } + }} + > + + + ) : null} Date: Tue, 24 Mar 2026 03:31:21 +0200 Subject: [PATCH 32/44] Split studio pin shell and photo symbol layers --- src/components/maps/queue-map.native.tsx | 53 +++++++++++++++++++----- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/src/components/maps/queue-map.native.tsx b/src/components/maps/queue-map.native.tsx index 48d6e31..fbd9921 100644 --- a/src/components/maps/queue-map.native.tsx +++ b/src/components/maps/queue-map.native.tsx @@ -50,26 +50,19 @@ const MAP_LOADING_OVERLAY_DELAY_MS = 180; function buildStudioPinDataUri({ accentColor, - imageUrl, label, textColor, }: { accentColor: string; - imageUrl?: string; label: string; textColor: string; }) { - const safeImageUrl = imageUrl ? imageUrl.replaceAll("&", "&") : null; const safeLabel = label.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); const svg = ` - ${ - safeImageUrl - ? `` - : `${safeLabel}` - } + ${safeLabel} `.trim(); return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; @@ -145,7 +138,6 @@ export const QueueMap = memo(function QueueMap({ `${STUDIO_PIN_ICON_KEY_PREFIX}${studio.studioId}`, buildStudioPinDataUri({ accentColor: mapPalette.markerAccent, - ...(studio.logoImageUrl ? { imageUrl: studio.logoImageUrl } : {}), label: studio.studioName.slice(0, 1).toUpperCase(), textColor: palette.onPrimary as string, }), @@ -165,11 +157,25 @@ export const QueueMap = memo(function QueueMap({ properties: { studioId: studio.studioId, iconKey: `${STUDIO_PIN_ICON_KEY_PREFIX}${studio.studioId}`, + ...(studio.logoImageUrl + ? { photoIconKey: `${STUDIO_PIN_ICON_KEY_PREFIX}${studio.studioId}:photo` } + : {}), }, })), }), [studios], ); + const studioPhotoImages = useMemo( + () => + Object.fromEntries( + studios.flatMap((studio) => + studio.logoImageUrl + ? [[`${STUDIO_PIN_ICON_KEY_PREFIX}${studio.studioId}:photo`, studio.logoImageUrl]] + : [], + ), + ), + [studios], + ); const handleRetry = useCallback(() => { setBaseMapStyle(null); setMapErrorMessage(null); @@ -395,7 +401,9 @@ export const QueueMap = memo(function QueueMap({ onPressZone={onPressZone} /> - {showStudioMarkers ? : null} + {showStudioMarkers ? ( + + ) : null} {showStudioMarkers ? ( + ) : null} From 898baa769c6ea6ea4322dc53ba451c0ab4d4ac94 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 24 Mar 2026 03:32:39 +0200 Subject: [PATCH 33/44] feat(instructor-profile): structured address fields + simplified location UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema - Added addressCity, addressStreet, addressNumber, addressFloor, addressPostalCode to instructorProfiles and studioProfiles tables (convex/schema.ts) Convex backend - Updated getMyInstructorSettings / updateMyInstructorSettings to read/write all 5 new structured fields (convex/users.ts) - Updated getMyStudioSettings / updateMyStudioSettings to read/write all 5 new structured fields (convex/users.ts) OSM address parsing (src/lib/google-places.ts) - Extended PlaceCoordinates type with city?, street?, streetNumber?, postalCode? - Rewrote fetchOsmAutocomplete to parse OSM addressdetails, extract structured parts, cache them, and return cleaner mainText (street+number) and secondaryText (city, postal code) - Added fetchPlaceByZipCode() for Israeli postal code lookup via OSM Nominatim (countrycodes=il) Location resolution (src/lib/location-zone.ts) - Extended ResolvedLocation type with structured address fields - Rewrote reverseGeocodeOnWeb and geocodeAddressOnWeb to extract and return structured address data from OSM - Simplified formatAddress — outputs only "{streetNumber} {street} | {city}, {postalCode}", removed subregion/region noise - Updated resolveCoordinatesToZone and resolveAddressToZone to populate structured fields UI (src/app/(app)/(instructor-tabs)/instructor/profile/location.tsx) - Rewrote with distill principles: removed hero card, single search bar as primary path, auto-filled structured address summary below search bar, manual mode as quiet escape-hatch link, zip lookup as secondary action link, compact zone section with dot indicator - Added ManualField + FieldLabel sub-components with NativeWind 5 (no hardcoded values, BrandSpacing/BrandType/palette tokens throughout) - All new translation strings added to both en.ts and he.ts i18n - Added English + Hebrew translation keys: enterManually, backToSearch, findByZip, fieldCity/Street/Number/Floor/ZipCode, fieldCityPlaceholder, fieldStreetPlaceholder, zipNotFound Lint - Fixed import ordering in onboarding.tsx, profile-role-switcher-card.tsx, address-autocomplete.tsx, tw/index.tsx Top sheet - Reworked the global top sheet transition path so the sheet frame stays anchored while content animates inside it - Switched content transitions to a lightweight fade and restored animation coverage for sticky header/footer rich-render paths - Fixed multiple layout regressions in the transition wrapper that caused hidden content in map, home, calendar, and profile tabs - Removed jobs-specific local header layout animation so instructor jobs uses the same global top-sheet behavior as other tabs - Measured real profile header/subpage header content to drive collapsed sheet height instead of relying on hardcoded step guesses --- app.json | 4 +- convex/schema.ts | 10 + convex/users.ts | 74 ++ ...idit-sdk-react-native-android-activity.mjs | 6 +- .../instructor/jobs/studios/[studioId].tsx | 4 +- .../profile/identity-verification.tsx | 9 +- .../instructor/profile/index.tsx | 47 +- .../instructor/profile/location.tsx | 667 +++++++++++------- .../(studio-tabs)/studio/profile/index.tsx | 39 +- src/app/_layout.tsx | 8 +- src/app/modal.tsx | 6 +- src/app/onboarding.tsx | 13 +- src/components/home/home-agenda-widget.tsx | 3 +- src/components/home/home-tab/index.tsx | 2 +- src/components/home/studio-home-content.tsx | 7 +- src/components/jobs/instructor-feed.tsx | 27 +- .../jobs/studio/studio-jobs-top-sheet.tsx | 4 +- src/components/layout/global-top-sheet.tsx | 321 ++++----- src/components/layout/top-sheet-registry.ts | 18 +- .../layout/top-sheet-search-bar.tsx | 20 +- src/components/layout/top-sheet.helpers.ts | 19 + src/components/layout/top-sheet.tsx | 10 +- src/components/loading-screen.tsx | 4 +- .../payments/payment-activity-list.tsx | 2 +- .../profile/profile-role-switcher-card.tsx | 4 +- .../profile/profile-settings-sections.tsx | 4 +- .../profile/profile-subpage-sheet.tsx | 44 +- src/components/ui/action-button.tsx | 31 +- src/components/ui/address-autocomplete.tsx | 4 +- src/components/ui/choice-pill.tsx | 48 +- src/components/ui/kit/kit-button-group.tsx | 19 +- src/components/ui/kit/kit-chip.tsx | 20 +- .../ui/kit/kit-disclosure-button-group.tsx | 120 ++-- src/components/ui/kit/kit-floating-badge.tsx | 5 +- src/components/ui/kit/kit-mesh-gradient.tsx | 96 +-- .../ui/kit/kit-segmented-toggle.tsx | 13 +- .../ui/kit/kit-social-icon-button.tsx | 12 +- src/components/ui/kit/kit-success-burst.tsx | 40 +- src/components/ui/kit/kit-switch.tsx | 18 +- src/components/ui/kit/kit-text-field.tsx | 15 +- src/components/ui/kit/use-kit-theme.ts | 6 +- src/components/ui/native-search-field.tsx | 26 +- src/components/ui/sheet-header-block.tsx | 21 +- src/contexts/system-ui-context.tsx | 2 +- src/i18n/translations/en.ts | 11 + src/i18n/translations/he.ts | 11 + src/lib/google-places.ts | 121 +++- src/lib/location-zone.ts | 168 ++++- src/tw/index.tsx | 13 +- tsconfig.json | 11 +- 50 files changed, 1364 insertions(+), 843 deletions(-) diff --git a/app.json b/app.json index 6d6019f..da38230 100644 --- a/app.json +++ b/app.json @@ -15,9 +15,7 @@ "ExpoLocalization_supportsRTL": true, "CFBundleLocalizations": ["en", "he", "en", "he"], "NFCReaderUsageDescription": "NFC is used to read passport and identity document chips during verification.", - "com.apple.developer.nfc.readersession.iso7816.select-identifiers": [ - "A0000002471001" - ] + "com.apple.developer.nfc.readersession.iso7816.select-identifiers": ["A0000002471001"] }, "bundleIdentifier": "com.derpcat.queue", "privacyManifests": { diff --git a/convex/schema.ts b/convex/schema.ts index 72c541f..649021c 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -99,6 +99,11 @@ export default defineSchema({ bio: v.optional(v.string()), socialLinks: v.optional(socialLinksValidator), address: v.optional(v.string()), + addressCity: v.optional(v.string()), + addressStreet: v.optional(v.string()), + addressNumber: v.optional(v.string()), + addressFloor: v.optional(v.string()), + addressPostalCode: v.optional(v.string()), latitude: v.optional(v.number()), longitude: v.optional(v.number()), expoPushToken: v.optional(v.string()), @@ -236,6 +241,11 @@ export default defineSchema({ bio: v.optional(v.string()), socialLinks: v.optional(socialLinksValidator), address: v.string(), + addressCity: v.optional(v.string()), + addressStreet: v.optional(v.string()), + addressNumber: v.optional(v.string()), + addressFloor: v.optional(v.string()), + addressPostalCode: v.optional(v.string()), zone: v.string(), latitude: v.optional(v.number()), longitude: v.optional(v.number()), diff --git a/convex/users.ts b/convex/users.ts index a8f2373..204cf46 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -433,6 +433,11 @@ export const getMyInstructorSettings = query({ profileImageUrl: v.optional(v.string()), socialLinks: v.optional(socialLinksValidator), address: v.optional(v.string()), + addressCity: v.optional(v.string()), + addressStreet: v.optional(v.string()), + addressNumber: v.optional(v.string()), + addressFloor: v.optional(v.string()), + addressPostalCode: v.optional(v.string()), latitude: v.optional(v.number()), longitude: v.optional(v.number()), calendarProvider: v.union(v.literal("none"), v.literal("google"), v.literal("apple")), @@ -472,6 +477,11 @@ export const getMyInstructorSettings = query({ profileImageUrl, socialLinks: toOptionalSocialLinksPayload(profile.socialLinks), address: profile.address, + addressCity: profile.addressCity, + addressStreet: profile.addressStreet, + addressNumber: profile.addressNumber, + addressFloor: profile.addressFloor, + addressPostalCode: profile.addressPostalCode, latitude: profile.latitude, longitude: profile.longitude, }), @@ -488,6 +498,11 @@ export const updateMyInstructorSettings = mutation({ hourlyRateExpectation: v.optional(v.number()), sports: v.array(v.string()), address: v.optional(v.string()), + addressCity: v.optional(v.string()), + addressStreet: v.optional(v.string()), + addressNumber: v.optional(v.string()), + addressFloor: v.optional(v.string()), + addressPostalCode: v.optional(v.string()), latitude: v.optional(v.number()), longitude: v.optional(v.number()), includeDetectedZone: v.optional(v.boolean()), @@ -524,6 +539,23 @@ export const updateMyInstructorSettings = mutation({ throw new ConvexError("Too many sports selected"); } const address = normalizeOptionalString(args.address, MAX_ADDRESS_LENGTH, "Address"); + const addressCity = normalizeOptionalString( + args.addressCity, + MAX_ADDRESS_LENGTH, + "AddressCity", + ); + const addressStreet = normalizeOptionalString( + args.addressStreet, + MAX_ADDRESS_LENGTH, + "AddressStreet", + ); + const addressNumber = normalizeOptionalString(args.addressNumber, 20, "AddressNumber"); + const addressFloor = normalizeOptionalString(args.addressFloor, 20, "AddressFloor"); + const addressPostalCode = normalizeOptionalString( + args.addressPostalCode, + 20, + "AddressPostalCode", + ); const { latitude, longitude } = normalizeCoordinates( omitUndefined({ latitude: args.latitude, @@ -583,6 +615,11 @@ export const updateMyInstructorSettings = mutation({ ...omitUndefined({ hourlyRateExpectation: args.hourlyRateExpectation, address, + addressCity, + addressStreet, + addressNumber, + addressFloor, + addressPostalCode, latitude, longitude, }), @@ -682,6 +719,11 @@ export const getMyStudioSettings = query({ studioId: v.id("studioProfiles"), studioName: v.string(), address: v.string(), + addressCity: v.optional(v.string()), + addressStreet: v.optional(v.string()), + addressNumber: v.optional(v.string()), + addressFloor: v.optional(v.string()), + addressPostalCode: v.optional(v.string()), zone: v.string(), latitude: v.optional(v.number()), longitude: v.optional(v.number()), @@ -733,6 +775,11 @@ export const getMyStudioSettings = query({ contactPhone: profile.contactPhone, profileImageUrl, socialLinks: toOptionalSocialLinksPayload(profile.socialLinks), + addressCity: profile.addressCity, + addressStreet: profile.addressStreet, + addressNumber: profile.addressNumber, + addressFloor: profile.addressFloor, + addressPostalCode: profile.addressPostalCode, }), notificationsEnabled, hasExpoPushToken, @@ -849,6 +896,11 @@ export const updateMyStudioSettings = mutation({ args: { studioName: v.string(), address: v.string(), + addressCity: v.optional(v.string()), + addressStreet: v.optional(v.string()), + addressNumber: v.optional(v.string()), + addressFloor: v.optional(v.string()), + addressPostalCode: v.optional(v.string()), zone: v.string(), contactPhone: v.optional(v.string()), latitude: v.optional(v.number()), @@ -871,6 +923,23 @@ export const updateMyStudioSettings = mutation({ "Studio name", ); const address = normalizeRequiredString(args.address, MAX_ADDRESS_LENGTH, "Address"); + const addressCity = normalizeOptionalString( + args.addressCity, + MAX_ADDRESS_LENGTH, + "AddressCity", + ); + const addressStreet = normalizeOptionalString( + args.addressStreet, + MAX_ADDRESS_LENGTH, + "AddressStreet", + ); + const addressNumber = normalizeOptionalString(args.addressNumber, 20, "AddressNumber"); + const addressFloor = normalizeOptionalString(args.addressFloor, 20, "AddressFloor"); + const addressPostalCode = normalizeOptionalString( + args.addressPostalCode, + 20, + "AddressPostalCode", + ); const zone = normalizeZoneId(args.zone); const contactPhone = normalizeOptionalString( args.contactPhone, @@ -909,6 +978,11 @@ export const updateMyStudioSettings = mutation({ longitude, autoExpireMinutesBefore, autoAcceptDefault: args.autoAcceptDefault, + addressCity, + addressStreet, + addressNumber, + addressFloor, + addressPostalCode, }), updatedAt: Date.now(), }); diff --git a/scripts/patches/patch-didit-sdk-react-native-android-activity.mjs b/scripts/patches/patch-didit-sdk-react-native-android-activity.mjs index 2eca981..c8359c8 100644 --- a/scripts/patches/patch-didit-sdk-react-native-android-activity.mjs +++ b/scripts/patches/patch-didit-sdk-react-native-android-activity.mjs @@ -48,13 +48,13 @@ patched = patched.replace( ); patched = patched.replace( - " private suspend fun awaitReadyAndLaunchUI(promise: Promise, activity: android.app.Activity?) {\n if (activity == null) {\n Log.e(TAG, \"awaitReadyAndLaunchUI: no active Activity at call time\")\n val errorResult = mapVerificationResult(\n VerificationResult.Failed(\n error = VerificationError.Unknown(\"No active Activity available to present verification UI.\"),\n session = null\n )\n )\n promise.resolve(errorResult)\n return\n }\n\n val TIMEOUT_MS = 30_000L\n", + ' private suspend fun awaitReadyAndLaunchUI(promise: Promise, activity: android.app.Activity?) {\n if (activity == null) {\n Log.e(TAG, "awaitReadyAndLaunchUI: no active Activity at call time")\n val errorResult = mapVerificationResult(\n VerificationResult.Failed(\n error = VerificationError.Unknown("No active Activity available to present verification UI."),\n session = null\n )\n )\n promise.resolve(errorResult)\n return\n }\n\n val TIMEOUT_MS = 30_000L\n', " private suspend fun awaitReadyAndLaunchUI(promise: Promise) {\n val TIMEOUT_MS = 30_000L\n", ); patched = patched.replace( - " is DiditSdkState.Ready -> {\n Log.d(TAG, \"awaitReadyAndLaunchUI: launching verification UI\")\n DiditSdk.launchVerificationUI(activity)\n true\n }\n", - " is DiditSdkState.Ready -> {\n val activity = currentActivity\n if (activity == null) {\n Log.e(TAG, \"awaitReadyAndLaunchUI: no active Activity available when SDK became ready\")\n val errorResult = mapVerificationResult(\n VerificationResult.Failed(\n error = VerificationError.Unknown(\"No active Activity available to present verification UI.\"),\n session = null\n )\n )\n promise.resolve(errorResult)\n return@first true\n }\n Log.d(TAG, \"awaitReadyAndLaunchUI: launching verification UI\")\n DiditSdk.launchVerificationUI(activity)\n true\n }\n", + ' is DiditSdkState.Ready -> {\n Log.d(TAG, "awaitReadyAndLaunchUI: launching verification UI")\n DiditSdk.launchVerificationUI(activity)\n true\n }\n', + ' is DiditSdkState.Ready -> {\n val activity = currentActivity\n if (activity == null) {\n Log.e(TAG, "awaitReadyAndLaunchUI: no active Activity available when SDK became ready")\n val errorResult = mapVerificationResult(\n VerificationResult.Failed(\n error = VerificationError.Unknown("No active Activity available to present verification UI."),\n session = null\n )\n )\n promise.resolve(errorResult)\n return@first true\n }\n Log.d(TAG, "awaitReadyAndLaunchUI: launching verification UI")\n DiditSdk.launchVerificationUI(activity)\n true\n }\n', ); if (patched === original) { diff --git a/src/app/(app)/(instructor-tabs)/instructor/jobs/studios/[studioId].tsx b/src/app/(app)/(instructor-tabs)/instructor/jobs/studios/[studioId].tsx index 982b9e2..76522e3 100644 --- a/src/app/(app)/(instructor-tabs)/instructor/jobs/studios/[studioId].tsx +++ b/src/app/(app)/(instructor-tabs)/instructor/jobs/studios/[studioId].tsx @@ -173,7 +173,7 @@ export default function InstructorStudioProfileRoute() { draggable: false, expandable: false, backgroundColor: palette.primary as string, - topInsetColor: "transparent", + topInsetColor: palette.primary as string, }; }, [ palette.onPrimary, @@ -247,7 +247,7 @@ export default function InstructorStudioProfileRoute() { tone="error" message={applyErrorMessage} onDismiss={() => setApplyErrorMessage(null)} - borderColor="transparent" + borderColor={palette.danger} backgroundColor={palette.dangerSubtle} textColor={palette.danger} iconColor={palette.danger} diff --git a/src/app/(app)/(instructor-tabs)/instructor/profile/identity-verification.tsx b/src/app/(app)/(instructor-tabs)/instructor/profile/identity-verification.tsx index cbe94be..0998a3e 100644 --- a/src/app/(app)/(instructor-tabs)/instructor/profile/identity-verification.tsx +++ b/src/app/(app)/(instructor-tabs)/instructor/profile/identity-verification.tsx @@ -179,13 +179,13 @@ function LinkPill({ } function LoaderDot({ delay, color }: { delay: number; color: string }) { - const pulse = useSharedValue(0.45); + const pulse = useSharedValue(0.72); useEffect(() => { pulse.value = withDelay( delay, withRepeat( - withSequence(withTiming(1, { duration: 420 }), withTiming(0.45, { duration: 420 })), + withSequence(withTiming(1, { duration: 420 }), withTiming(0.72, { duration: 420 })), -1, false, ), @@ -193,7 +193,6 @@ function LoaderDot({ delay, color }: { delay: number; color: string }) { }, [delay, pulse]); const animatedStyle = useAnimatedStyle(() => ({ - opacity: pulse.value, transform: [{ scale: 0.8 + pulse.value * 0.35 }], })); @@ -233,22 +232,18 @@ function VerificationResolvingState({ label }: { label: string }) { }, [bubbleFloat, cardFloat, halo, settle]); const haloStyle = useAnimatedStyle(() => ({ - opacity: 0.18 + halo.value * 0.18, transform: [{ scale: halo.value }], })); const cardStyle = useAnimatedStyle(() => ({ - opacity: 0.72 + settle.value * 0.28, transform: [{ translateY: cardFloat.value }, { scale: 0.96 + settle.value * 0.04 }], })); const bubbleLeftStyle = useAnimatedStyle(() => ({ - opacity: 0.16 + settle.value * 0.1, transform: [{ translateY: bubbleFloat.value }, { scale: 0.92 + settle.value * 0.08 }], })); const bubbleRightStyle = useAnimatedStyle(() => ({ - opacity: 0.14 + settle.value * 0.1, transform: [{ translateY: bubbleFloat.value * -0.7 }, { scale: 0.9 + settle.value * 0.1 }], })); diff --git a/src/app/(app)/(instructor-tabs)/instructor/profile/index.tsx b/src/app/(app)/(instructor-tabs)/instructor/profile/index.tsx index dbb493c..4e7a930 100644 --- a/src/app/(app)/(instructor-tabs)/instructor/profile/index.tsx +++ b/src/app/(app)/(instructor-tabs)/instructor/profile/index.tsx @@ -10,6 +10,7 @@ import { StyleSheet, useWindowDimensions, View } from "react-native"; import { TabScreenRoot } from "@/components/layout/tab-screen-root"; import { useGlobalTopSheet } from "@/components/layout/top-sheet-registry"; import { useDeferredTabMount } from "@/components/layout/use-deferred-tab-mount"; +import { useMeasuredContentHeight } from "@/components/layout/use-measured-content-height"; import { ProfileRoleSwitcherCard } from "@/components/profile/profile-role-switcher-card"; import { ProfileSectionCard, @@ -18,7 +19,6 @@ import { } from "@/components/profile/profile-settings-sections"; import { ProfileIndexScrollView } from "@/components/profile/profile-subpage-sheet"; import { - getProfileHeaderExpandedHeight, ProfileDesktopHeroPanel, ProfileHeaderSheet, } from "@/components/profile/profile-tab"; @@ -33,6 +33,7 @@ import { useBrand } from "@/hooks/use-brand"; import { useLayoutBreakpoint } from "@/hooks/use-layout-breakpoint"; import { useThemePreference } from "@/hooks/use-theme-preference"; import { buildRoleTabRoute, ROLE_TAB_ROUTE_NAMES } from "@/navigation/role-routes"; +import { getTopSheetAvailableHeight } from "@/components/layout/top-sheet.helpers"; const ROLE_TRANSLATION_KEYS = { pending: "profile.roles.pending", @@ -247,29 +248,36 @@ export default function InstructorProfileScreen() { (socialCount > 0 ? t("profile.settings.publicProfileActive", { count: socialCount }) : t("profile.settings.publicProfilePrompt")); - const profileHeaderHeight = useMemo(() => getProfileHeaderExpandedHeight(safeTop), [safeTop]); + const { measuredHeight: profileMeasuredHeight, onLayout: onProfileHeaderLayout } = + useMeasuredContentHeight(); + const profileHeaderHeight = useMemo( + () => safeTop + (profileMeasuredHeight > 0 ? profileMeasuredHeight : 128), + [profileMeasuredHeight, safeTop], + ); const profileSheetStep = useMemo(() => { - const availableHeight = Math.max(1, screenHeight - safeTop - 80); + const availableHeight = Math.max(1, getTopSheetAvailableHeight(screenHeight, safeTop, 0)); return Math.max(0.12, Math.min(0.34, profileHeaderHeight / availableHeight)); }, [profileHeaderHeight, safeTop, screenHeight]); const profileSheetContent = useMemo( () => ( - + + + ), [ currentUser?.image, @@ -280,6 +288,7 @@ export default function InstructorProfileScreen() { instructorSettings?.socialLinks, instructorSettings?.sports, nameValue, + onProfileHeaderLayout, palette, profileStatus, t, diff --git a/src/app/(app)/(instructor-tabs)/instructor/profile/location.tsx b/src/app/(app)/(instructor-tabs)/instructor/profile/location.tsx index 05c846d..3c73be1 100644 --- a/src/app/(app)/(instructor-tabs)/instructor/profile/location.tsx +++ b/src/app/(app)/(instructor-tabs)/instructor/profile/location.tsx @@ -2,7 +2,7 @@ import { useMutation, useQuery } from "convex/react"; import { useRouter } from "expo-router"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Text, View } from "react-native"; +import { Pressable, Text, TextInput, View } from "react-native"; import { LoadingScreen } from "@/components/loading-screen"; import { @@ -13,7 +13,6 @@ import { ProfileSubpageScrollView, useProfileSubpageSheet, } from "@/components/profile/profile-subpage-sheet"; -import { StatusSignal } from "@/components/profile/status-signal"; import { ActionButton } from "@/components/ui/action-button"; import { AddressAutocomplete } from "@/components/ui/address-autocomplete"; import { IconSymbol } from "@/components/ui/icon-symbol"; @@ -24,7 +23,11 @@ import { api } from "@/convex/_generated/api"; import { useAppInsets } from "@/hooks/use-app-insets"; import { useBrand } from "@/hooks/use-brand"; import { useLocationResolution } from "@/hooks/use-location-resolution"; -import type { PlaceCoordinates } from "@/lib/google-places"; +import { + fetchPlaceByZipCode, + type PlaceCoordinates, + type ZipCodeResult, +} from "@/lib/google-places"; import { getLocationResolveErrorMessage } from "@/lib/location-error-message"; import type { ResolvedLocation } from "@/lib/location-zone"; @@ -41,12 +44,64 @@ async function getZoneLabelById(zoneId: string, language: "en" | "he"): Promise< return labelsByZoneId.get(zoneId)?.[language] ?? zoneId; } -function formatCoordinateLabel(latitude?: number, longitude?: number) { - if (latitude === undefined || longitude === undefined) { - return null; - } +function hasContent(str: string | undefined | null) { + return str !== undefined && str !== null && str.trim().length > 0; +} - return `${latitude.toFixed(4)}, ${longitude.toFixed(4)}`; +function FieldLabel({ children }: { children: string }) { + return ( + + {children} + + ); +} + +function ManualField({ + value, + onChangeText, + placeholder, + palette, + keyboardType = "default", + autoCapitalize = "words", +}: { + value: string; + onChangeText: (text: string) => void; + placeholder: string; + palette: ReturnType; + keyboardType?: "default" | "numeric" | "email-address"; + autoCapitalize?: "none" | "words" | "sentences"; +}) { + return ( + + ); } export default function LocationScreen() { @@ -68,7 +123,20 @@ export default function LocationScreen() { const saveInstructor = useMutation(api.users.updateMyInstructorSettings); const locationResolver = useLocationResolution(); + // Search address bar const [addressInput, setAddressInput] = useState(""); + + // Structured fields — auto-filled from search/GPS, editable when manual mode + const [city, setCity] = useState(""); + const [street, setStreet] = useState(""); + const [streetNumber, setStreetNumber] = useState(""); + const [floor, setFloor] = useState(""); + const [postalCode, setPostalCode] = useState(""); + + // Manual entry mode + const [manualMode, setManualMode] = useState(false); + + // Shared location state const [latitude, setLatitude] = useState(); const [longitude, setLongitude] = useState(); const [detectedZone, setDetectedZone] = useState(null); @@ -78,9 +146,19 @@ export default function LocationScreen() { const [errorMessage, setErrorMessage] = useState(null); const [seeded, setSeeded] = useState(false); + // Zip lookup state + const [zipResults, setZipResults] = useState([]); + const [isSearchingZip, setIsSearchingZip] = useState(false); + const [showZipResults, setShowZipResults] = useState(false); + useEffect(() => { if (instructorSettings && !seeded) { setAddressInput(instructorSettings.address ?? ""); + setCity(instructorSettings.addressCity ?? ""); + setStreet(instructorSettings.addressStreet ?? ""); + setStreetNumber(instructorSettings.addressNumber ?? ""); + setFloor(instructorSettings.addressFloor ?? ""); + setPostalCode(instructorSettings.addressPostalCode ?? ""); setLatitude(instructorSettings.latitude); setLongitude(instructorSettings.longitude); setSeeded(true); @@ -95,9 +173,7 @@ export default function LocationScreen() { let cancelled = false; void getZoneLabelById(detectedZone, languageCode).then((label) => { - if (cancelled) { - return; - } + if (cancelled) return; setDetectedZoneLabel(label); }); @@ -113,6 +189,10 @@ export default function LocationScreen() { const applyResolution = useCallback((resolved: ResolvedLocation) => { setAddressInput(resolved.address); + setCity(resolved.city ?? ""); + setStreet(resolved.street ?? ""); + setStreetNumber(resolved.streetNumber ?? ""); + setPostalCode(resolved.postalCode ?? ""); setLatitude(resolved.latitude); setLongitude(resolved.longitude); setErrorMessage(null); @@ -156,11 +236,12 @@ export default function LocationScreen() { latitude: coords.latitude, longitude: coords.longitude, zoneId: "", + ...(coords.city !== undefined ? { city: coords.city } : {}), + ...(coords.street !== undefined ? { street: coords.street } : {}), + ...(coords.streetNumber !== undefined ? { streetNumber: coords.streetNumber } : {}), + ...(coords.postalCode !== undefined ? { postalCode: coords.postalCode } : {}), }); - void resolveZoneFromCoordinates({ - latitude: coords.latitude, - longitude: coords.longitude, - }); + void resolveZoneFromCoordinates({ latitude: coords.latitude, longitude: coords.longitude }); }, [applyResolution, resolveZoneFromCoordinates], ); @@ -176,6 +257,44 @@ export default function LocationScreen() { [clearDetectedZone], ); + const applyZipResult = useCallback( + (result: ZipCodeResult) => { + setCity(result.city ?? ""); + setStreet(result.street ?? ""); + setStreetNumber(result.streetNumber ?? ""); + setPostalCode(result.postalCode ?? ""); + setAddressInput(result.formattedAddress); + setLatitude(result.latitude); + setLongitude(result.longitude); + setZipResults([]); + setShowZipResults(false); + setErrorMessage(null); + void resolveZoneFromCoordinates({ latitude: result.latitude, longitude: result.longitude }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [resolveZoneFromCoordinates], + ); + + const handleZipCodeLookup = useCallback(async () => { + const cleaned = postalCode.replace(/\s+/g, "").trim(); + if (cleaned.length < 5) return; + setIsSearchingZip(true); + setShowZipResults(false); + setErrorMessage(null); + try { + const results = await fetchPlaceByZipCode(cleaned); + setZipResults(results); + setShowZipResults(true); + if (results.length === 0) { + setErrorMessage(t("profile.location.zipNotFound")); + } + } catch { + setErrorMessage(t("profile.settings.errors.locationResolveFailed")); + } finally { + setIsSearchingZip(false); + } + }, [postalCode, t]); + const resolveByGps = useCallback(async () => { const result = await locationResolver.resolveFromGps(); if (!result.ok) { @@ -210,26 +329,25 @@ export default function LocationScreen() { const trimmedAddressInput = addressInput.trim(); const initialAddress = instructorSettings.address?.trim() ?? ""; - const coordinateLabel = formatCoordinateLabel(latitude, longitude); - const hasAddress = trimmedAddressInput.length > 0; const hasDetectedZone = Boolean(detectedZone); + + // Show structured summary when we have data from search/GPS but NOT in manual mode + const showStructuredSummary = + !manualMode && + (hasContent(city) || hasContent(street) || hasContent(streetNumber) || hasContent(postalCode)); + const hasLocationChanges = trimmedAddressInput !== initialAddress || latitude !== instructorSettings.latitude || - longitude !== instructorSettings.longitude; + longitude !== instructorSettings.longitude || + city !== (instructorSettings.addressCity ?? "") || + street !== (instructorSettings.addressStreet ?? "") || + streetNumber !== (instructorSettings.addressNumber ?? "") || + floor !== (instructorSettings.addressFloor ?? "") || + postalCode !== (instructorSettings.addressPostalCode ?? ""); const hasZoneAssignment = hasDetectedZone && includeDetectedZone; const hasChanges = hasLocationChanges || hasZoneAssignment; - const heroTitle = hasDetectedZone - ? t("profile.location.heroReady") - : hasAddress - ? t("profile.location.heroPending") - : t("profile.location.heroMissing"); - const heroBody = hasDetectedZone - ? t("profile.location.heroReadyBody") - : hasAddress - ? t("profile.location.heroPendingBody") - : t("profile.location.heroMissingBody"); const zoneDisplayValue = detectedZone ? (detectedZoneLabel ?? detectedZone) : t("profile.settings.location.zoneNotDetected"); @@ -253,6 +371,11 @@ export default function LocationScreen() { ? { hourlyRateExpectation: instructorSettings.hourlyRateExpectation } : {}), ...(trimmedAddressInput ? { address: trimmedAddressInput } : {}), + ...(city.trim() ? { addressCity: city.trim() } : {}), + ...(street.trim() ? { addressStreet: street.trim() } : {}), + ...(streetNumber.trim() ? { addressNumber: streetNumber.trim() } : {}), + ...(floor.trim() ? { addressFloor: floor.trim() } : {}), + ...(postalCode.trim() ? { addressPostalCode: postalCode.trim() } : {}), ...(latitude !== undefined ? { latitude } : {}), ...(longitude !== undefined ? { longitude } : {}), includeDetectedZone, @@ -266,6 +389,17 @@ export default function LocationScreen() { } }; + // --- Render helpers --- + const renderStructuredRow = (label: string, value: string) => { + if (!value) return null; + return ( + + {label} + {value} + + ); + }; + return ( - - - - - {t("profile.settings.location.title").toUpperCase()} - - - {heroTitle} - - - {heroBody} - - - - - - + {/* ── Address section ── */} + - - - - - + {/* Search bar */} + + - - { + void resolveByGps(); + }} + disabled={locationResolver.isResolving} palette={palette} - flush + tone="secondary" + fullWidth /> - - - - - {t("profile.location.addressTitle")} - - - {t("profile.location.addressBody")} - + + + {/* Structured address summary — auto-filled from search/GPS */} + {showStructuredSummary ? ( + + {renderStructuredRow(`${t("profile.location.fieldCity")}:`, city)} + {renderStructuredRow( + `${t("profile.location.fieldStreet")}:`, + street ? `${streetNumber ?? ""} ${street}`.trim() : "", + )} + {hasContent(floor) + ? renderStructuredRow(`${t("profile.location.fieldFloor")}:`, floor) + : null} + {hasContent(postalCode) + ? renderStructuredRow(`${t("profile.location.fieldZipCode")}:`, postalCode) + : null} + + ) : null} + + {/* Structured fields — editable in manual mode */} + {manualMode ? ( + + {/* Row: city + street */} + + + {t("profile.location.fieldCity")} + + + + {t("profile.location.fieldStreet")} + + + + + {/* Row: number + floor + zip */} + + + {t("profile.location.fieldNumber")} + + + + {t("profile.location.fieldFloor")} + + + {t("profile.location.fieldZipCode")} + + + + + ) : null} - + {!manualMode ? ( + setManualMode(true)} className="flex-row items-center gap-1"> + + + {t("profile.location.enterManually")} + + + ) : ( + setManualMode(false)} className="flex-row items-center gap-1"> + + + {t("profile.location.backToSearch")} + + + )} - + + { - void resolveByGps(); + void handleZipCodeLookup(); }} - disabled={locationResolver.isResolving} - palette={palette} - tone="secondary" - fullWidth - /> - - {coordinateLabel ? ( - + + - - {t("profile.location.coordinatesLabel")} - - - {coordinateLabel} - - - ) : null} - - + {t("profile.location.findByZip")} + + + + )} - - - - - 0 ? ( + + {zipResults.slice(0, 5).map((result) => ( + { + applyZipResult(result); + setManualMode(false); + }} + className="px-3 py-2" + style={({ pressed }) => ({ + backgroundColor: pressed ? (palette.primarySubtle as string) : (palette.surfaceElevated as string), - }} + borderBottomWidth: 1, + borderBottomColor: palette.border as string, + })} > - {hasDetectedZone - ? t("profile.location.zoneDetectedLabel") - : t("profile.location.zoneWaitingLabel")} + {result.street + ? `${result.streetNumber ?? ""} ${result.street}`.trim() + : result.formattedAddress.split(",")[0]} - {zoneDisplayValue} + {[result.city, result.postalCode].filter(Boolean).join(" · ")} - + ))} + + ) : null} + + {/* ── Zone section ── */} + + + + + {/* Zone status row */} + + + - {hasDetectedZone - ? t("profile.settings.location.includeDetectedZoneDescription") - : t("profile.location.zonePendingBody")} + /> + + {zoneDisplayValue} - {hasDetectedZone ? ( - - - - {t("profile.settings.location.includeDetectedZone")} - - - {t("profile.settings.location.includeDetectedZoneDescription")} - - - - + ) : null} - - + {!hasDetectedZone ? ( + + {t("profile.location.zonePendingBody")} + + ) : ( + + {t("profile.settings.location.includeDetectedZoneDescription")} + + )} + + + + {/* Error */} {errorMessage ? ( - + {errorMessage} ) : null} + {/* Save rail */} 0 ? t("profile.settings.publicProfileActive", { count: socialCount }) : t("profile.settings.publicProfilePrompt")); - const profileHeaderHeight = useMemo(() => getProfileHeaderExpandedHeight(safeTop), [safeTop]); + const { measuredHeight: profileMeasuredHeight, onLayout: onProfileHeaderLayout } = + useMeasuredContentHeight(); + const profileHeaderHeight = useMemo( + () => safeTop + (profileMeasuredHeight > 0 ? profileMeasuredHeight : 128), + [profileMeasuredHeight, safeTop], + ); const profileSheetStep = useMemo(() => { - const availableHeight = Math.max(1, screenHeight - safeTop - 80); + const availableHeight = Math.max(1, getTopSheetAvailableHeight(screenHeight, safeTop, 0)); return Math.max(0.12, Math.min(0.34, profileHeaderHeight / availableHeight)); }, [profileHeaderHeight, safeTop, screenHeight]); const profileSheetContent = useMemo( () => ( - + + + ), [ currentUser?.image, handleRequestEdit, + onProfileHeaderLayout, palette, profileName, profileStatus, diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 19376d4..bccd97e 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -146,11 +146,9 @@ function RootLayoutContent() { ? palette.primary : topInsetTone === "card" ? palette.surface - : topInsetTone === "transparent" - ? "transparent" - : topInsetTone === "app" - ? palette.primary - : palette.appBg; + : topInsetTone === "app" + ? palette.primary + : palette.appBg; const statusInsetColor = topInsetBackgroundColor ?? fallbackBackgroundColor; return ( diff --git a/src/app/modal.tsx b/src/app/modal.tsx index 48bf035..5564c04 100644 --- a/src/app/modal.tsx +++ b/src/app/modal.tsx @@ -11,7 +11,11 @@ export default function ModalScreen() { return ( {t("modal.title")} - + {t("modal.goHome")} diff --git a/src/app/onboarding.tsx b/src/app/onboarding.tsx index 29b8f8b..3917a64 100644 --- a/src/app/onboarding.tsx +++ b/src/app/onboarding.tsx @@ -35,11 +35,11 @@ import { useBrand } from "@/hooks/use-brand"; import { useLocationResolution } from "@/hooks/use-location-resolution"; import { getLocationResolveErrorMessage } from "@/lib/location-error-message"; import { omitUndefined } from "@/lib/omit-undefined"; -import { buildRoleTabRoute, ROLE_TAB_ROUTE_NAMES } from "@/navigation/role-routes"; import { isPushRegistrationError, registerForPushNotificationsAsync, } from "@/lib/push-notifications"; +import { buildRoleTabRoute, ROLE_TAB_ROUTE_NAMES } from "@/navigation/role-routes"; type OnboardingRole = "instructor" | "studio"; type OnboardingStep = 0 | 1 | 2; @@ -97,7 +97,6 @@ function OnboardingStageLayer({ children: React.ReactNode; }) { const translateX = useSharedValue(0); - const opacity = useSharedValue(1); useEffect(() => { const incomingOffset = 20 * direction; @@ -105,24 +104,19 @@ function OnboardingStageLayer({ if (phase === "enter") { translateX.value = incomingOffset; - opacity.value = 0; translateX.value = withTiming(0, { duration: STEP_ENTER_MS }); - opacity.value = withTiming(1, { duration: STEP_ENTER_MS }); return; } if (phase === "exit") { translateX.value = withTiming(outgoingOffset, { duration: STEP_EXIT_MS }); - opacity.value = withTiming(0, { duration: STEP_EXIT_MS }); return; } translateX.value = 0; - opacity.value = 1; - }, [direction, opacity, phase, translateX]); + }, [direction, phase, translateX]); const animatedStyle = useAnimatedStyle(() => ({ - opacity: opacity.value, transform: [{ translateX: translateX.value }], })); @@ -211,8 +205,7 @@ function OnboardingScreenContent() { const requestedRole = isOnboardingRole(roleParam) ? roleParam : null; const ownedRoles = currentUser?.roles ?? []; - const isAdditionalProfileSetup = - requestedRole !== null && !ownedRoles.includes(requestedRole); + const isAdditionalProfileSetup = requestedRole !== null && !ownedRoles.includes(requestedRole); const isForcedWorkspaceSetup = isAdditionalProfileSetup && ownedRoles.length > 0; useEffect(() => { diff --git a/src/components/home/home-agenda-widget.tsx b/src/components/home/home-agenda-widget.tsx index 4699ae7..f09fc7a 100644 --- a/src/components/home/home-agenda-widget.tsx +++ b/src/components/home/home-agenda-widget.tsx @@ -202,8 +202,7 @@ export function HomeAgendaWidget({ borderRadius: BrandRadius.pill, backgroundColor: isToday ? (palette.primary as string) - : (palette.textMuted as string), - opacity: isToday ? 1 : 0.3, + : (palette.textMicro as string), }} /> diff --git a/src/components/home/home-tab/index.tsx b/src/components/home/home-tab/index.tsx index 125efdd..061118b 100644 --- a/src/components/home/home-tab/index.tsx +++ b/src/components/home/home-tab/index.tsx @@ -145,7 +145,7 @@ export default function HomeScreen() { [activeRole, homeSheetContent, homeSheetStep, palette], ); - useGlobalTopSheet("index", homeSheetConfig); + useGlobalTopSheet("index", homeSheetConfig, "home:sheet"); if (isAuthLoading) { return ; diff --git a/src/components/home/studio-home-content.tsx b/src/components/home/studio-home-content.tsx index 2190884..1afe697 100644 --- a/src/components/home/studio-home-content.tsx +++ b/src/components/home/studio-home-content.tsx @@ -13,7 +13,7 @@ import { useScrollSheetBindings } from "@/components/layout/scroll-sheet-provide import { TabScreenScrollView } from "@/components/layout/tab-screen-scroll-view"; import { ActionButton } from "@/components/ui/action-button"; import type { BrandPalette } from "@/constants/brand"; -import { BrandSpacing, BrandType } from "@/constants/brand"; +import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; import { getZoneLabel } from "@/constants/zones"; import { toSportLabel } from "@/convex/constants"; import { useAppInsets } from "@/hooks/use-app-insets"; @@ -102,7 +102,6 @@ export function StudioHomeContent({ ...BrandType.micro, color: palette.onPrimary as string, letterSpacing: 0.8, - opacity: 0.76, }} > {t("home.studio.title")} @@ -121,7 +120,6 @@ export function StudioHomeContent({ style={{ ...BrandType.body, color: palette.onPrimary as string, - opacity: 0.84, }} > {jobsNeedingReview.length > 0 @@ -218,7 +216,8 @@ export function StudioHomeContent({ accessibilityLabel={t("home.actions.jobsTitle")} onPress={onOpenJobs} style={({ pressed }) => ({ - opacity: pressed ? 0.94 : 1, + borderRadius: BrandRadius.soft, + backgroundColor: pressed ? (palette.surfaceElevated as string) : undefined, })} > diff --git a/src/components/jobs/instructor-feed.tsx b/src/components/jobs/instructor-feed.tsx index a101a8e..99b0e5e 100644 --- a/src/components/jobs/instructor-feed.tsx +++ b/src/components/jobs/instructor-feed.tsx @@ -5,7 +5,6 @@ import { Redirect, useRouter } from "expo-router"; import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { RefreshControl, StyleSheet, View } from "react-native"; -import Animated, { LinearTransition, ReduceMotion } from "react-native-reanimated"; import { type InstructorArchiveRow, InstructorJobsArchiveSheet, @@ -167,38 +166,29 @@ export function InstructorFeed() { ] as const satisfies readonly KitDisclosureButtonGroupOption<"all" | "24h" | "72h">[], [t], ); - const headerLayoutTransition = useMemo( - () => LinearTransition.duration(220).reduceMotion(ReduceMotion.System), - [], - ); const jobsSheetConfig = useMemo( () => ({ stickyHeader: ( - - + - + - - + + - - + + {applyErrorMessage ? ( setApplyErrorMessage(null)} /> ) : null} - + ), padding: { vertical: BrandSpacing.sm, @@ -249,7 +239,6 @@ export function InstructorFeed() { jobsFilterOptions, jobsWindowFilter, jobsSearchQuery, - headerLayoutTransition, palette, showJobsFilters, t, diff --git a/src/components/jobs/studio/studio-jobs-top-sheet.tsx b/src/components/jobs/studio/studio-jobs-top-sheet.tsx index be7c026..0a86255 100644 --- a/src/components/jobs/studio/studio-jobs-top-sheet.tsx +++ b/src/components/jobs/studio/studio-jobs-top-sheet.tsx @@ -73,8 +73,8 @@ export function StudioJobsTopSheetHeader({ /> } size="sm" - railColor={String(palette.primarySubtle)} - selectedColor={String(palette.surface)} + railColor={String(palette.surface)} + selectedColor={String(palette.primarySubtle)} labelColor={String(palette.text)} selectedLabelColor={String(palette.primaryPressed)} dividerColor={String(palette.border)} diff --git a/src/components/layout/global-top-sheet.tsx b/src/components/layout/global-top-sheet.tsx index 6ac4a7b..96da61b 100644 --- a/src/components/layout/global-top-sheet.tsx +++ b/src/components/layout/global-top-sheet.tsx @@ -1,20 +1,10 @@ import { usePathname } from "expo-router"; -import { isValidElement, useCallback, useEffect, useRef, useState } from "react"; -import { - type LayoutChangeEvent, - Platform, - StyleSheet, - useWindowDimensions, - View, -} from "react-native"; +import { isValidElement, useCallback, useEffect, useRef } from "react"; +import { Platform, StyleSheet, type StyleProp, useWindowDimensions, View, type ViewStyle } from "react-native"; import Reanimated, { - FadeInDown, - FadeOutUp, + FadeIn, + FadeOut, ReduceMotion, - SlideInLeft, - SlideInRight, - SlideOutLeft, - SlideOutRight, useReducedMotion, } from "react-native-reanimated"; @@ -30,12 +20,10 @@ import { import { useAppInsets } from "@/hooks/use-app-insets"; import { useBrand } from "@/hooks/use-brand"; import { - areSheetConfigsEqual, - type ContentTransitionDirection, getFallbackSheetColors, - getRouteDepth, resolveTopSheetRouteTab, } from "./global-top-sheet.helpers"; +import { getTopSheetStepHeights } from "./top-sheet.helpers"; /** * One global TopSheet mounted in RoleTabsLayout above NativeTabs. @@ -49,7 +37,7 @@ import { */ export function GlobalTopSheet() { const pathname = usePathname(); - const { safeTop } = useAppInsets(); + const { safeBottom, safeTop } = useAppInsets(); const { height: screenHeight } = useWindowDimensions(); const palette = useBrand(); const rootStyle = Platform.OS === "web" ? undefined : styles.overlayRoot; @@ -64,108 +52,93 @@ export function GlobalTopSheet() { // ── ScrollY from provider (for custom animated sheets) ───────────── const { setCollapsedSheetHeight } = useScrollSheetLayout(); const scrollY = useScrollSheetScrollValue(); + const measuredHeightRef = useRef(null); + const transitionKey = activeRouteKey ?? activeTabId ?? activeConfig?.tabId ?? "global-top-sheet"; + const fallbackColors = activeConfig + ? getFallbackSheetColors(activeConfig.tabId, palette) + : { + backgroundColor: palette.primary as string, + topInsetColor: palette.primary as string, + }; - const [transitionDirection, setTransitionDirection] = - useState("vertical"); - const previousRouteKeyRef = useRef(activeRouteKey); - const previousConfigRef = useRef(activeConfig); + const baseSheetProps = activeConfig + ? { + ...(activeConfig.draggable !== undefined ? { draggable: activeConfig.draggable } : {}), + ...(activeConfig.expandable !== undefined ? { expandable: activeConfig.expandable } : {}), + ...(activeConfig.steps ? { steps: activeConfig.steps } : {}), + ...(activeConfig.initialStep !== undefined + ? { initialStep: activeConfig.initialStep } + : {}), + ...(activeConfig.activeStep !== undefined ? { activeStep: activeConfig.activeStep } : {}), + ...(activeConfig.expandMode ? { expandMode: activeConfig.expandMode } : {}), + ...(activeConfig.padding ? { padding: activeConfig.padding } : {}), + backgroundColor: + (activeConfig.backgroundColor as string | undefined) ?? fallbackColors.backgroundColor, + topInsetColor: + (activeConfig.topInsetColor as string | undefined) ?? fallbackColors.topInsetColor, + ...(activeConfig.style ? { style: activeConfig.style } : {}), + ...(activeConfig.onStepChange ? { onStepChange: activeConfig.onStepChange } : {}), + ...(activeConfig.stickyHeader ? { stickyHeader: activeConfig.stickyHeader } : {}), + ...(activeConfig.stickyFooter ? { stickyFooter: activeConfig.stickyFooter } : {}), + ...(activeConfig.revealOnExpand ? { revealOnExpand: activeConfig.revealOnExpand } : {}), + } + : null; + const hasRenderableContent = Boolean( + activeConfig && + (activeConfig.render || + activeConfig.content || + activeConfig.stickyHeader || + activeConfig.stickyFooter || + activeConfig.revealOnExpand || + activeConfig.overlay), + ); + const renderResult = activeConfig?.render ? activeConfig.render({ scrollY }) : null; + const isRichResult = + typeof renderResult === "object" && + renderResult !== null && + !isValidElement(renderResult) && + !Array.isArray(renderResult); + const richResult = isRichResult + ? (renderResult as Omit, "children"> & { + children?: React.ReactNode; + }) + : null; + const staticCollapsedHeight = + activeConfig && (!activeConfig.render || richResult) + ? (getTopSheetStepHeights( + richResult?.steps ?? activeConfig.steps ?? [0.18, 0.4, 0.65, 0.95], + screenHeight, + safeTop, + safeBottom, + )[0] ?? DEFAULT_SHEET_PADDING_TOP) + safeTop + : null; useEffect(() => { - const previousRouteKey = previousRouteKeyRef.current; - const previousConfig = previousConfigRef.current; - - if (!activeRouteKey || !activeConfig || reduceMotionEnabled) { - setTransitionDirection("vertical"); - previousRouteKeyRef.current = activeRouteKey; - previousConfigRef.current = activeConfig; + if (!activeConfig) { + setCollapsedSheetHeight(DEFAULT_SHEET_PADDING_TOP); return; } - if (previousRouteKey === activeRouteKey) { - if (!areSheetConfigsEqual(previousConfig, activeConfig)) { - setTransitionDirection("vertical"); - } - previousConfigRef.current = activeConfig; + if (staticCollapsedHeight !== null) { + setCollapsedSheetHeight(staticCollapsedHeight); return; } - const displayedTabId = resolveTopSheetRouteTab(previousRouteKey); - const nextTabId = resolveTopSheetRouteTab(activeRouteKey); - const isSameTabRouteChange = displayedTabId !== null && displayedTabId === nextTabId; - - if (isSameTabRouteChange) { - const currentDepth = getRouteDepth(previousRouteKey); - const nextDepth = getRouteDepth(activeRouteKey); - setTransitionDirection(nextDepth >= currentDepth ? "forward" : "backward"); - } else { - setTransitionDirection("vertical"); - } - - previousRouteKeyRef.current = activeRouteKey; - previousConfigRef.current = activeConfig; - }, [activeConfig, activeRouteKey, reduceMotionEnabled]); + setCollapsedSheetHeight(measuredHeightRef.current ?? DEFAULT_SHEET_PADDING_TOP); + }, [activeConfig, setCollapsedSheetHeight, staticCollapsedHeight]); - // ── Measure collapsed sheet height for tab content padding ───────── - const [measuredHeight, setMeasuredHeight] = useState(null); - const measuredHeightRef = useRef(null); - - const handleLayout = useCallback((e: LayoutChangeEvent) => { - const h = e.nativeEvent.layout.height; - if (h <= 0) { - return; - } - if (measuredHeightRef.current !== null && Math.abs(measuredHeightRef.current - h) < 1) { - return; - } - measuredHeightRef.current = h; - setMeasuredHeight(h); - }, []); - - const fallbackHeight = (() => { - const fallbackConfig = activeConfig; - if (!fallbackConfig) return DEFAULT_SHEET_PADDING_TOP; - const steps = fallbackConfig.steps ?? [0.18, 0.4, 0.65, 0.95]; - const collapsedStep = steps[0] ?? 0.18; - return collapsedStep * screenHeight + safeTop; - })(); - - const collapsedSheetHeight = measuredHeight ?? fallbackHeight; - - useEffect(() => { - setCollapsedSheetHeight(collapsedSheetHeight); - }, [collapsedSheetHeight, setCollapsedSheetHeight]); - - // ── Render nothing if no config ───────────────────────────────────── - if (!activeConfig) return null; - - const transitionKey = activeRouteKey ?? activeTabId ?? activeConfig.tabId; - const fallbackColors = getFallbackSheetColors(activeConfig.tabId, palette); - - const baseSheetProps = { - ...(activeConfig.draggable !== undefined ? { draggable: activeConfig.draggable } : {}), - ...(activeConfig.expandable !== undefined ? { expandable: activeConfig.expandable } : {}), - ...(activeConfig.steps ? { steps: activeConfig.steps } : {}), - ...(activeConfig.initialStep !== undefined ? { initialStep: activeConfig.initialStep } : {}), - ...(activeConfig.activeStep !== undefined ? { activeStep: activeConfig.activeStep } : {}), - ...(activeConfig.expandMode ? { expandMode: activeConfig.expandMode } : {}), - ...(activeConfig.padding ? { padding: activeConfig.padding } : {}), - backgroundColor: - (activeConfig.backgroundColor as string | undefined) ?? fallbackColors.backgroundColor, - topInsetColor: - (activeConfig.topInsetColor as string | undefined) ?? fallbackColors.topInsetColor, - ...(activeConfig.style ? { style: activeConfig.style } : {}), - ...(activeConfig.onStepChange ? { onStepChange: activeConfig.onStepChange } : {}), - ...(activeConfig.stickyHeader ? { stickyHeader: activeConfig.stickyHeader } : {}), - ...(activeConfig.stickyFooter ? { stickyFooter: activeConfig.stickyFooter } : {}), - ...(activeConfig.revealOnExpand ? { revealOnExpand: activeConfig.revealOnExpand } : {}), - }; - const hasRenderableContent = Boolean( - activeConfig.render || - activeConfig.content || - activeConfig.stickyHeader || - activeConfig.stickyFooter || - activeConfig.revealOnExpand || - activeConfig.overlay, + const handleMeasuredLayout = useCallback( + (height: number) => { + if (height <= 0) { + return; + } + if (measuredHeightRef.current !== null && Math.abs(measuredHeightRef.current - height) < 1) { + return; + } + measuredHeightRef.current = height; + setCollapsedSheetHeight(height); + }, + [setCollapsedSheetHeight], ); const contentTransitionProps = (() => { @@ -173,56 +146,33 @@ export function GlobalTopSheet() { return {}; } - if (transitionDirection === "forward") { - return { - entering: SlideInRight.duration(240).reduceMotion(ReduceMotion.System), - exiting: SlideOutLeft.duration(180).reduceMotion(ReduceMotion.System), - }; - } - - if (transitionDirection === "backward") { - return { - entering: SlideInLeft.duration(240).reduceMotion(ReduceMotion.System), - exiting: SlideOutRight.duration(180).reduceMotion(ReduceMotion.System), - }; - } - return { - entering: FadeInDown.duration(240).reduceMotion(ReduceMotion.System), - exiting: FadeOutUp.duration(180).reduceMotion(ReduceMotion.System), + entering: FadeIn.duration(140).reduceMotion(ReduceMotion.System), + exiting: FadeOut.duration(90).reduceMotion(ReduceMotion.System), }; })(); const renderTransitionedNode = ( slotKey: string, node: React.ReactNode, - style?: React.ComponentProps["style"], + style?: StyleProp, ) => { if (!node) return null; return ( - - {node} - + + + {node} + + ); }; + // ── Render nothing if no config ───────────────────────────────────── + if (!activeConfig) return null; + // ── Render function mode ──────────────────────────────────────────── if (activeConfig.render) { - const result = activeConfig.render({ scrollY }); - - const isRichResult = - typeof result === "object" && - result !== null && - !isValidElement(result) && - !Array.isArray(result); - - if (isRichResult) { - const rich = result as Omit, "children"> & { - children?: React.ReactNode; - }; + if (richResult && baseSheetProps) { + const rich = richResult; const { children: richChildren, stickyHeader: richStickyHeader, @@ -233,30 +183,48 @@ export function GlobalTopSheet() { return ( - - + {renderTransitionedNode("children", richChildren, richChildren ? { flex: 1 } : undefined)} + + {activeConfig.overlay ? ( + - {renderTransitionedNode( - "children", - richChildren, - richChildren ? { flex: 1 } : undefined, - )} - - - {renderTransitionedNode("overlay", activeConfig.overlay, styles.overlayLayer)} + {activeConfig.overlay} + + ) : null} ); } return ( - {result as React.ReactNode} - {renderTransitionedNode("overlay", activeConfig.overlay, styles.overlayLayer)} + { + handleMeasuredLayout(event.nativeEvent.layout.height); + }} + > + {renderResult as React.ReactNode} + + {activeConfig.overlay ? ( + + {activeConfig.overlay} + + ) : null} ); } @@ -267,18 +235,18 @@ export function GlobalTopSheet() { return ( - - - - {activeConfig.content} - - - - {renderTransitionedNode("overlay", activeConfig.overlay, styles.overlayLayer)} + + {renderTransitionedNode("content", activeConfig.content, activeConfig.content ? { flex: 1 } : undefined)} + + {activeConfig.overlay ? ( + + {activeConfig.overlay} + + ) : null} ); } @@ -291,6 +259,9 @@ const styles = StyleSheet.create({ right: 0, zIndex: 40, }, + contentClip: { + overflow: "hidden", + }, overlayLayer: { ...StyleSheet.absoluteFillObject, zIndex: 120, diff --git a/src/components/layout/top-sheet-registry.ts b/src/components/layout/top-sheet-registry.ts index b66ccd9..615bba7 100644 --- a/src/components/layout/top-sheet-registry.ts +++ b/src/components/layout/top-sheet-registry.ts @@ -4,7 +4,7 @@ import { type PropsWithChildren, useCallback, useContext, - useLayoutEffect, + useEffect, useMemo, useRef, useState, @@ -229,24 +229,18 @@ export function useGlobalTopSheet( explicitOwnerId?: string, ) { const { replaceConfig, clearConfig } = useTopSheetRegistry(); - const latestTabIdRef = useRef(tabId); const ownerIdRef = useRef(null); if (!ownerIdRef.current) { ownerIdRef.current = `${tabId}:${Math.random().toString(36).slice(2, 10)}`; } const ownerId = explicitOwnerId ?? ownerIdRef.current; - latestTabIdRef.current = tabId; - useLayoutEffect(() => { + useEffect(() => { replaceConfig(tabId, ownerId, config); - }, [config, ownerId, replaceConfig, tabId]); - - useLayoutEffect( - () => () => { - clearConfig(latestTabIdRef.current, ownerId); - }, - [clearConfig, ownerId], - ); + return () => { + clearConfig(tabId, ownerId); + }; + }, [clearConfig, config, ownerId, replaceConfig, tabId]); } export function useResolvedTabSheetConfig(tabId: string | null) { diff --git a/src/components/layout/top-sheet-search-bar.tsx b/src/components/layout/top-sheet-search-bar.tsx index 5a31014..377d562 100644 --- a/src/components/layout/top-sheet-search-bar.tsx +++ b/src/components/layout/top-sheet-search-bar.tsx @@ -43,6 +43,8 @@ export function TopSheetSearchBar({ setIsFocused(false); onBlur?.(); }; + const pressedBackgroundColor = (palette.surface ?? palette.surfaceAlt) as string; + const clearButtonColor = palette.text as string; return ( @@ -90,9 +99,12 @@ export function TopSheetSearchBar({ onChangeText("")} hitSlop={8} - style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} + style={({ pressed }) => ({ + borderRadius: BrandRadius.pill, + backgroundColor: pressed ? pressedBackgroundColor : clearButtonColor, + })} > - + ) : null} diff --git a/src/components/layout/top-sheet.helpers.ts b/src/components/layout/top-sheet.helpers.ts index 46df802..11c564a 100644 --- a/src/components/layout/top-sheet.helpers.ts +++ b/src/components/layout/top-sheet.helpers.ts @@ -12,3 +12,22 @@ export const HANDLE_HEIGHT = BrandSpacing.xl + BrandSpacing.md; export const HANDLE_PILL_WIDTH = 36; export const HANDLE_PILL_HEIGHT = 4; export const MIN_BOTTOM_CHROME_ESTIMATE = 80; + +export function getTopSheetAvailableHeight( + screenHeight: number, + safeTop: number, + safeBottom: number, +) { + const bottomChromeEstimate = Math.max(MIN_BOTTOM_CHROME_ESTIMATE, safeBottom + 64); + return screenHeight - safeTop - bottomChromeEstimate; +} + +export function getTopSheetStepHeights( + steps: readonly number[], + screenHeight: number, + safeTop: number, + safeBottom: number, +) { + const availableHeight = getTopSheetAvailableHeight(screenHeight, safeTop, safeBottom); + return steps.map((step) => Math.round(step * availableHeight)); +} diff --git a/src/components/layout/top-sheet.tsx b/src/components/layout/top-sheet.tsx index 5211bb2..bfd64b8 100644 --- a/src/components/layout/top-sheet.tsx +++ b/src/components/layout/top-sheet.tsx @@ -15,10 +15,10 @@ import { useAppInsets } from "@/hooks/use-app-insets"; import { useBrand } from "@/hooks/use-brand"; import { DEFAULT_STEPS, + getTopSheetStepHeights, HANDLE_HEIGHT, HANDLE_PILL_HEIGHT, HANDLE_PILL_WIDTH, - MIN_BOTTOM_CHROME_ESTIMATE, SHEET_SPRING, } from "./top-sheet.helpers"; import { TopSheetSearchBar } from "./top-sheet-search-bar"; @@ -141,14 +141,10 @@ export function TopSheet({ }; }, [resolvedInsetColor, setTopInsetBackgroundColor, setTopInsetTone]); - // Available height for sheet steps (screen minus safe top minus bottom tabs) - const bottomChromeEstimate = Math.max(MIN_BOTTOM_CHROME_ESTIMATE, safeBottom + 64); - const availableHeight = screenHeight - safeTop - bottomChromeEstimate; - // Compute step heights in pixels const stepHeights = useMemo( - () => steps.map((s) => Math.round(s * availableHeight)), - [steps, availableHeight], + () => getTopSheetStepHeights(steps, screenHeight, safeTop, safeBottom), + [safeBottom, safeTop, screenHeight, steps], ); // Sheet height shared value diff --git a/src/components/loading-screen.tsx b/src/components/loading-screen.tsx index e9db742..e1d7846 100644 --- a/src/components/loading-screen.tsx +++ b/src/components/loading-screen.tsx @@ -61,8 +61,7 @@ export function LoadingScreen({ width: LAUNCH_ICON_SIZE, height: LAUNCH_ICON_SIZE, borderRadius: LAUNCH_ICON_RADIUS, - backgroundColor: palette.onPrimary as string, - opacity: 0.1, + backgroundColor: palette.primaryPressed as string, position: "absolute", }} /> @@ -113,7 +112,6 @@ export function LoadingScreen({ type="caption" style={{ color: palette.onPrimary as string, - opacity: 0.78, textAlign: "center", }} selectable diff --git a/src/components/payments/payment-activity-list.tsx b/src/components/payments/payment-activity-list.tsx index 7c1d40d..e5f731e 100644 --- a/src/components/payments/payment-activity-list.tsx +++ b/src/components/payments/payment-activity-list.tsx @@ -121,7 +121,7 @@ export function PaymentActivityList({ className="flex-row items-center justify-between px-md py-md" style={({ pressed }) => ({ backgroundColor: - pressed && onSelectPaymentId ? palette.surfaceAlt : "transparent", + pressed && onSelectPaymentId ? palette.surfaceAlt : palette.surface, borderBottomWidth: index < items.length - 1 ? 1 : 0, borderBottomColor: palette.border, })} diff --git a/src/components/profile/profile-role-switcher-card.tsx b/src/components/profile/profile-role-switcher-card.tsx index 628fa6b..67e0372 100644 --- a/src/components/profile/profile-role-switcher-card.tsx +++ b/src/components/profile/profile-role-switcher-card.tsx @@ -1,7 +1,7 @@ -import { ActivityIndicator } from "react-native"; import { useTranslation } from "react-i18next"; +import { ActivityIndicator } from "react-native"; -import { type BrandPalette } from "@/constants/brand"; +import type { BrandPalette } from "@/constants/brand"; import type { AppRole } from "@/navigation/types"; import { ProfileSectionCard, ProfileSettingRow } from "./profile-settings-sections"; diff --git a/src/components/profile/profile-settings-sections.tsx b/src/components/profile/profile-settings-sections.tsx index 8d91bc6..83ce2b8 100644 --- a/src/components/profile/profile-settings-sections.tsx +++ b/src/components/profile/profile-settings-sections.tsx @@ -186,14 +186,14 @@ export function ProfileSettingRow({ ? resolvedAccentColor : (palette.primary as string); - const borderColor = tone === "danger" ? "transparent" : (palette.border as string); + const borderColor = tone === "danger" ? (palette.danger as string) : (palette.border as string); const rowBackground = tone === "accent" ? resolvedScheme === "dark" ? (palette.accentRowBgDark as string) : (palette.accentRowBgLight as string) - : "transparent"; + : (palette.surface as string); const content = ( diff --git a/src/components/profile/profile-subpage-sheet.tsx b/src/components/profile/profile-subpage-sheet.tsx index 35a6978..7c39a9f 100644 --- a/src/components/profile/profile-subpage-sheet.tsx +++ b/src/components/profile/profile-subpage-sheet.tsx @@ -10,10 +10,19 @@ import { useState, } from "react"; import { useTranslation } from "react-i18next"; -import { I18nManager, type StyleProp, StyleSheet, View, type ViewStyle } from "react-native"; +import { + I18nManager, + type StyleProp, + StyleSheet, + useWindowDimensions, + View, + type ViewStyle, +} from "react-native"; import { useCollapsedSheetHeight } from "@/components/layout/scroll-sheet-provider"; import { TabScreenScrollView } from "@/components/layout/tab-screen-scroll-view"; import { useGlobalTopSheet } from "@/components/layout/top-sheet-registry"; +import { getTopSheetAvailableHeight } from "@/components/layout/top-sheet.helpers"; +import { useMeasuredContentHeight } from "@/components/layout/use-measured-content-height"; import { ThemedText } from "@/components/themed-text"; import { IconButton } from "@/components/ui/icon-button"; import { IconSymbol } from "@/components/ui/icon-symbol"; @@ -176,7 +185,11 @@ export function ProfileSubpageSheetHost({ const palette = useBrand(); const router = useRouter(); const pathname = usePathname(); + const { safeBottom, safeTop } = useAppInsets(); + const { height: screenHeight } = useWindowDimensions(); const accessoryContext = useContext(ProfileSubpageAccessoryContext); + const { measuredHeight: headerMeasuredHeight, onLayout: onHeaderLayout } = + useMeasuredContentHeight(); const activeRoute = useMemo( () => @@ -201,18 +214,28 @@ export function ProfileSubpageSheetHost({ return { stickyHeader: ( - router.back()} - {...(isDiditRoute || isPaymentsRoute ? { accentColor } : {})} - /> + + router.back()} + {...(isDiditRoute || isPaymentsRoute ? { accentColor } : {})} + /> + ), padding: { vertical: BrandSpacing.stackTight, horizontal: BrandSpacing.inset, }, - steps: [0.12], + steps: [ + Math.max( + 0.12, + (safeTop + + (headerMeasuredHeight > 0 ? headerMeasuredHeight : PROFILE_SUBPAGE_HEADER_HEIGHT) + + BrandSpacing.stackTight * 2) / + Math.max(1, getTopSheetAvailableHeight(screenHeight, safeTop, safeBottom)), + ), + ], initialStep: 0, draggable: false, expandable: false, @@ -222,10 +245,15 @@ export function ProfileSubpageSheetHost({ }, [ activeRoute, accessoryContext?.accessories, + headerMeasuredHeight, + onHeaderLayout, palette.primary, palette.didit.accent, palette.payments.accent, router, + safeBottom, + safeTop, + screenHeight, ]); useGlobalTopSheet("profile", config, ownerId); diff --git a/src/components/ui/action-button.tsx b/src/components/ui/action-button.tsx index 068aaa0..cf8fe6b 100644 --- a/src/components/ui/action-button.tsx +++ b/src/components/ui/action-button.tsx @@ -1,6 +1,5 @@ import type { ReactNode } from "react"; import { ActivityIndicator, I18nManager, Pressable, Text, View } from "react-native"; -import { MeshGradientView } from "@/components/ui/kit"; import type { BrandPalette } from "@/constants/brand"; import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; @@ -10,7 +9,7 @@ type ActionButtonSize = "md" | "lg"; // Button heights follow the spacing scale for consistency const BUTTON_HEIGHT_LG = BrandSpacing.iconContainer + BrandSpacing.md; // 38 + 12 = 50px -const BUTTON_HEIGHT_MD = BrandSpacing.iconContainer + BrandSpacing.xs; // 38 + 4 = 42px +const BUTTON_HEIGHT_MD = BrandSpacing.iconContainer + BrandSpacing.xs; // 38 + 4 = 42px const BUTTON_MIN_WIDTH = BrandSpacing.iconContainer * 2 + BrandSpacing.sm; // 38*2 + 8 = 84px type ActionButtonProps = { @@ -25,7 +24,6 @@ type ActionButtonProps = { accessibilityLabel?: string; shape?: ActionButtonShape; size?: ActionButtonSize; - /** Use mesh gradient background instead of solid color (Vercel/Linear style) */ meshGradient?: boolean; }; @@ -41,7 +39,7 @@ export function ActionButton({ accessibilityLabel, shape = "pill", size = "md", - meshGradient = false, + meshGradient: _meshGradient = false, }: ActionButtonProps) { if (!label && !icon) { throw new Error("ActionButton requires a label, an icon, or both."); @@ -51,7 +49,6 @@ export function ActionButton({ const isIconOnly = Boolean(icon) && !label; const minHeight = size === "lg" ? BUTTON_HEIGHT_LG : BUTTON_HEIGHT_MD; const minWidth = shape === "square" ? minHeight : BUTTON_MIN_WIDTH; - const useMeshGradient = meshGradient && tone === "primary" && !isDisabled; const backgroundColor = isDisabled ? tone === "primary" ? (palette.primaryPressed as string) @@ -59,6 +56,11 @@ export function ActionButton({ : tone === "primary" ? (palette.primary as string) : (palette.surfaceAlt as string); + const pressedBackgroundColor = isDisabled + ? backgroundColor + : tone === "primary" + ? (palette.primaryPressed as string) + : (palette.surfaceElevated as string); const textColor = isDisabled ? tone === "primary" ? (palette.onPrimary as string) @@ -89,9 +91,8 @@ export function ActionButton({ paddingVertical: shape === "square" ? 0 : 10, borderRadius, borderCurve: "continuous", - backgroundColor: useMeshGradient ? "transparent" : backgroundColor, + backgroundColor: pressed && !isDisabled ? pressedBackgroundColor : backgroundColor, overflow: "hidden", - opacity: pressed && !isDisabled ? 0.92 : 1, })} > {loading ? ( @@ -121,21 +122,5 @@ export function ActionButton({ ); - if (useMeshGradient) { - return ( - - {buttonContent} - - ); - } - return buttonContent; } diff --git a/src/components/ui/address-autocomplete.tsx b/src/components/ui/address-autocomplete.tsx index 003b3ec..4094e38 100644 --- a/src/components/ui/address-autocomplete.tsx +++ b/src/components/ui/address-autocomplete.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { type ColorValue, Pressable, StyleSheet, TextInput, View } from "react-native"; import { ThemedText } from "@/components/themed-text"; +import { BrandRadius, BrandSpacing } from "@/constants/brand"; import { useBrand } from "@/hooks/use-brand"; import { fetchPlaceAutocomplete, @@ -11,7 +12,6 @@ import { type PlacePrediction, resetPlacesSession, } from "@/lib/google-places"; -import { BrandRadius, BrandSpacing } from "@/constants/brand"; type AddressAutocompleteProps = { value: string; @@ -170,7 +170,7 @@ export function AddressAutocomplete({ style={({ pressed }) => [ styles.suggestion, { - backgroundColor: pressed ? palette.primarySubtle : "transparent", + backgroundColor: pressed ? palette.primarySubtle : palette.surface, }, ]} onPress={() => { diff --git a/src/components/ui/choice-pill.tsx b/src/components/ui/choice-pill.tsx index 218316b..a138fdf 100644 --- a/src/components/ui/choice-pill.tsx +++ b/src/components/ui/choice-pill.tsx @@ -47,6 +47,15 @@ export function ChoicePill({ const resolvedLabelColor = selected ? (selectedLabelColor ?? palette.onPrimary) : (labelColor ?? palette.text); + const pressedBackgroundColor = selected + ? (palette.primaryPressed as ColorValue) + : (palette.surfaceElevated as ColorValue); + const disabledBackgroundColor = selected + ? (palette.primarySubtle as ColorValue) + : (palette.surface as ColorValue); + const disabledLabelColor = selected + ? (palette.primary as ColorValue) + : (palette.textMuted as ColorValue); return ( - + {icon ? {icon} : null} + - {icon ? {icon} : null} - - {label} - - + {label} + ); } diff --git a/src/components/ui/kit/kit-button-group.tsx b/src/components/ui/kit/kit-button-group.tsx index ee2fd01..851d00d 100644 --- a/src/components/ui/kit/kit-button-group.tsx +++ b/src/components/ui/kit/kit-button-group.tsx @@ -92,17 +92,16 @@ export function KitButtonGroup({ const resolvedGroupBg = groupBackgroundColor ?? - (tone === "onPrimary" ? `${String(palette.text)}CC` : String(palette.surfaceAlt)); + (tone === "onPrimary" ? String(palette.primaryPressed) : String(palette.surfaceAlt)); const resolvedSelectedBg = selectedBackgroundColor ?? - (tone === "onPrimary" ? `${String(palette.onPrimary)}33` : String(palette.surfaceElevated)); + (tone === "onPrimary" ? String(palette.primary) : String(palette.surfaceElevated)); const resolvedLabelColorFinal = - labelColor ?? - (tone === "onPrimary" ? `${String(palette.onPrimary)}B8` : String(palette.textMuted)); + labelColor ?? (tone === "onPrimary" ? String(palette.onPrimary) : String(palette.textMuted)); const resolvedSelectedLabelColorFinal = selectedLabelColor ?? String(palette.onPrimary); const resolvedDividerColorFinal = dividerColor ?? - (tone === "onPrimary" ? `${String(palette.onPrimary)}24` : String(palette.borderStrong)); + (tone === "onPrimary" ? String(palette.onPrimary) : String(palette.borderStrong)); return ( ({ { left: 0, width: StyleSheet.hairlineWidth, - opacity: 0.4, top: metrics.separatorInset, bottom: metrics.separatorInset, backgroundColor: resolvedDividerColorFinal, @@ -181,7 +179,14 @@ export function KitButtonGroup({ className="w-full" style={({ pressed }) => [ { - opacity: option.disabled ? 0.45 : pressed ? 0.9 : 1, + borderRadius: metrics.radius, + backgroundColor: option.disabled + ? String(palette.surface) + : pressed + ? tone === "onPrimary" + ? String(palette.primaryPressed) + : String(palette.surface) + : undefined, }, ]} > diff --git a/src/components/ui/kit/kit-chip.tsx b/src/components/ui/kit/kit-chip.tsx index 14192e2..9b9e47d 100644 --- a/src/components/ui/kit/kit-chip.tsx +++ b/src/components/ui/kit/kit-chip.tsx @@ -13,6 +13,17 @@ export function KitChip({ style, }: KitChipProps) { const palette = useBrand(); + const idleBackgroundColor = selected + ? (palette.primary as string) + : (palette.surfaceAlt as string); + const pressedBackgroundColor = selected + ? (palette.primaryPressed as string) + : (palette.surfaceElevated as string); + const disabledBackgroundColor = selected + ? (palette.primarySubtle as string) + : (palette.surface as string); + const textColor = selected ? (palette.onPrimary as string) : (palette.text as string); + const disabledTextColor = selected ? (palette.primary as string) : (palette.textMuted as string); return ( [ { - backgroundColor: selected ? (palette.primary as string) : (palette.surfaceAlt as string), - opacity: disabled ? 0.72 : 1, + backgroundColor: disabled + ? disabledBackgroundColor + : pressed + ? pressedBackgroundColor + : idleBackgroundColor, transform: [{ scale: pressed && !disabled ? 0.985 : 1 }], }, style, @@ -36,7 +50,7 @@ export function KitChip({ diff --git a/src/components/ui/kit/kit-disclosure-button-group.tsx b/src/components/ui/kit/kit-disclosure-button-group.tsx index e908ac1..412f37b 100644 --- a/src/components/ui/kit/kit-disclosure-button-group.tsx +++ b/src/components/ui/kit/kit-disclosure-button-group.tsx @@ -1,12 +1,7 @@ import type { ReactNode } from "react"; import type { ViewStyle } from "react-native"; import { Pressable, StyleSheet, Text, View } from "react-native"; -import Animated, { - FadeInRight, - FadeOutRight, - LinearTransition, - ReduceMotion, -} from "react-native-reanimated"; +import Animated, { LinearTransition, ReduceMotion } from "react-native-reanimated"; import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; import { useBrand } from "@/hooks/use-brand"; @@ -25,6 +20,7 @@ type KitDisclosureButtonGroupProps = { expanded: boolean; onChange: (value: T) => void; onToggleExpanded: () => void; + showTriggerWhenExpanded?: boolean; triggerLabel?: string; triggerIcon?: ReactNode; accessibilityLabel: string; @@ -40,7 +36,7 @@ type KitDisclosureButtonGroupProps = { const SIZE_PRESETS = { sm: { railPadding: BrandSpacing.xs, - railRadius: BrandRadius.cardSubtle, + railRadius: BrandRadius.buttonSubtle, sectionRadius: BrandRadius.buttonSubtle, minHeight: BrandSpacing.iconContainer, paddingHorizontal: BrandSpacing.componentPadding, @@ -48,7 +44,7 @@ const SIZE_PRESETS = { }, md: { railPadding: BrandSpacing.xs + 1, - railRadius: BrandRadius.cardSubtle + 2, + railRadius: BrandRadius.input, sectionRadius: BrandRadius.buttonSubtle, minHeight: BrandSpacing.iconContainer + 6, paddingHorizontal: BrandSpacing.lg, @@ -64,6 +60,7 @@ export function KitDisclosureButtonGroup({ expanded, onChange, onToggleExpanded, + showTriggerWhenExpanded = true, triggerLabel, triggerIcon, accessibilityLabel, @@ -77,32 +74,29 @@ export function KitDisclosureButtonGroup({ }: KitDisclosureButtonGroupProps) { const palette = useBrand(); const metrics = SIZE_PRESETS[size]; - const resolvedRailColor = railColor ?? `${String(palette.text)}CC`; - const resolvedSelectedColor = selectedColor ?? `${String(palette.onPrimary)}33`; - const resolvedLabelColor = labelColor ?? `${String(palette.onPrimary)}B8`; + const resolvedRailColor = railColor ?? String(palette.primaryPressed); + const resolvedSelectedColor = selectedColor ?? String(palette.primary); + const resolvedLabelColor = labelColor ?? String(palette.onPrimary); const resolvedSelectedLabelColor = selectedLabelColor ?? String(palette.onPrimary); - const resolvedDividerColor = dividerColor ?? `${String(palette.onPrimary)}1F`; + const resolvedDividerColor = dividerColor ?? String(palette.onPrimary); + const isIconOnlyTrigger = !triggerLabel; return ( {expanded ? ( - + {options.map((option, index) => { const selected = option.value === value; return ( @@ -115,7 +109,6 @@ export function KitDisclosureButtonGroup({ { left: 0, width: StyleSheet.hairlineWidth, - opacity: 0.5, top: metrics.separatorInset, bottom: metrics.separatorInset, backgroundColor: resolvedDividerColor, @@ -143,7 +136,12 @@ export function KitDisclosureButtonGroup({ onChange(option.value); }} className="relative z-10" - style={({ pressed }) => [{ opacity: pressed ? 0.9 : 1 }]} + style={({ pressed }) => [ + { + borderRadius: metrics.sectionRadius, + backgroundColor: pressed ? String(palette.primaryPressed) : undefined, + }, + ]} > ({ ) : null} - { - triggerSelectionHaptic(); - onToggleExpanded(); - }} - className="justify-center" - style={({ pressed }) => [ - { - opacity: pressed ? 0.92 : 1, - }, - ]} + - { + triggerSelectionHaptic(); + onToggleExpanded(); + }} + className="justify-center" + style={({ pressed }) => [ { - minHeight: metrics.minHeight, - paddingHorizontal: triggerLabel ? metrics.paddingHorizontal : BrandSpacing.md, - } satisfies ViewStyle, + borderRadius: metrics.sectionRadius, + backgroundColor: pressed ? String(palette.primaryPressed) : undefined, + }, ]} > - {triggerIcon ? {triggerIcon} : null} - {triggerLabel ? ( - - {triggerLabel} - - ) : null} - - + + {triggerIcon ? ( + {triggerIcon} + ) : null} + {triggerLabel ? ( + + {triggerLabel} + + ) : null} + + + ); } diff --git a/src/components/ui/kit/kit-floating-badge.tsx b/src/components/ui/kit/kit-floating-badge.tsx index 0e05201..112d149 100644 --- a/src/components/ui/kit/kit-floating-badge.tsx +++ b/src/components/ui/kit/kit-floating-badge.tsx @@ -30,7 +30,10 @@ export function KitFloatingBadge({ } floatOffset.value = withRepeat( - withSequence(withTiming(-BrandSpacing.xs - 1, { duration: 900 }), withTiming(0, { duration: 900 })), + withSequence( + withTiming(-BrandSpacing.xs - 1, { duration: 900 }), + withTiming(0, { duration: 900 }), + ), -1, false, ); diff --git a/src/components/ui/kit/kit-mesh-gradient.tsx b/src/components/ui/kit/kit-mesh-gradient.tsx index 9418f80..86266e5 100644 --- a/src/components/ui/kit/kit-mesh-gradient.tsx +++ b/src/components/ui/kit/kit-mesh-gradient.tsx @@ -1,52 +1,29 @@ import { useMemo } from "react"; -import { Pressable, StyleSheet, View, type ViewProps } from "react-native"; -import Svg, { Defs, Pattern, Rect } from "react-native-svg"; +import { Pressable, View, type ViewProps } from "react-native"; import type { MeshGradientPreset } from "@/constants/brand"; -import { BrandMeshGradient } from "@/constants/brand"; import { useBrand } from "@/hooks/use-brand"; import { useThemePreference } from "@/hooks/use-theme-preference"; type MeshGradientViewProps = ViewProps & { /** Which mesh gradient preset to use */ preset?: MeshGradientPreset; - /** Grain intensity multiplier (0-1). Defaults to preset value. */ + /** Retained for API compatibility; no visual grain is rendered. */ grainOpacity?: number; /** Border radius. Defaults to 0 (none). */ borderRadius?: number; - /** If true, renders as Pressable with opacity feedback */ + /** If true, renders as Pressable with solid pressed feedback */ pressable?: boolean; /** If true, uses dark variant (for light/dark independent control) */ darkVariant?: boolean; }; -type TexturedOverlayProps = { - tintColor: string; -}; - -function TexturedOverlay({ tintColor }: TexturedOverlayProps) { - return ( - - - - - - - - - - ); -} - /** * MeshGradientView * - * Renders a rich mesh gradient with subtle textured overlay. - * Uses stacked radial gradients via `experimental_backgroundImage` (New Architecture). - * Adds a repeating dot pattern for grain/texture feel. + * Renders a solid semantic surface that preserves the mesh API without alpha effects. */ export function MeshGradientView({ preset = "primary", - grainOpacity, borderRadius = 0, pressable = false, darkVariant = false, @@ -57,50 +34,40 @@ export function MeshGradientView({ const palette = useBrand(); const { resolvedScheme } = useThemePreference(); - const { gradient, grainOpacity: defaultGrainOpacity } = useMemo(() => { + const surfaceColor = useMemo(() => { const scheme = darkVariant ? "dark" : resolvedScheme; - return BrandMeshGradient[scheme][preset]; - }, [resolvedScheme, preset, darkVariant]); - - const effectiveGrainOpacity = grainOpacity ?? defaultGrainOpacity; - const tintColor = palette.onPrimary as string; + return preset === "primaryDark" + ? scheme === "dark" + ? (palette.primaryPressed as string) + : (palette.primaryPressed as string) + : (palette.primary as string); + }, [darkVariant, palette.primary, palette.primaryPressed, preset, resolvedScheme]); + const pressedSurfaceColor = + preset === "primaryDark" ? (palette.primary as string) : (palette.primaryPressed as string); const containerStyle = useMemo( () => [ - styles.base, { borderRadius, - experimental_backgroundImage: gradient, + overflow: "hidden" as const, + backgroundColor: surfaceColor, }, style, ], - [borderRadius, gradient, style], - ); - - const content = ( - - {children} - - - - + [borderRadius, style, surfaceColor], ); if (pressable) { return ( {({ pressed }) => ( - - {children} - - - + + {children} )} @@ -109,22 +76,7 @@ export function MeshGradientView({ return ( - {content} + {children} ); } - -const styles = StyleSheet.create({ - base: { - overflow: "hidden", - }, - container: { - ...StyleSheet.absoluteFillObject, - }, - textureOverlay: { - ...StyleSheet.absoluteFillObject, - }, - pressed: { - opacity: 0.92, - }, -}); diff --git a/src/components/ui/kit/kit-segmented-toggle.tsx b/src/components/ui/kit/kit-segmented-toggle.tsx index 11946d7..8c8d55b 100644 --- a/src/components/ui/kit/kit-segmented-toggle.tsx +++ b/src/components/ui/kit/kit-segmented-toggle.tsx @@ -24,6 +24,8 @@ export function KitSegmentedToggle({ style, }: KitSegmentedToggleProps) { const palette = useBrand(); + const disabledBackgroundColor = palette.surface as string; + const pressedBackgroundColor = palette.surfaceElevated as string; return ( ({ minHeight: BrandSpacing.iconContainer + 10, borderRadius: BrandRadius.buttonSubtle, borderCurve: "continuous", - backgroundColor: selected - ? (palette.primary as string) - : (palette.surfaceAlt as string), + backgroundColor: option.disabled + ? disabledBackgroundColor + : selected + ? (palette.primary as string) + : pressed + ? pressedBackgroundColor + : (palette.surfaceAlt as string), alignItems: "center", justifyContent: "center", - opacity: option.disabled ? 0.72 : pressed ? 0.86 : 1, })} > {icon === "website" ? ( @@ -71,7 +75,7 @@ export function KitSocialIconButton({ }} style={({ pressed }) => ({ borderRadius: BrandRadius.pill, - opacity: pressed ? 0.84 : 1, + backgroundColor: pressed ? pressedBackgroundColor : idleBackgroundColor, })} > {circle} diff --git a/src/components/ui/kit/kit-success-burst.tsx b/src/components/ui/kit/kit-success-burst.tsx index 949fc51..a5156e4 100644 --- a/src/components/ui/kit/kit-success-burst.tsx +++ b/src/components/ui/kit/kit-success-burst.tsx @@ -21,10 +21,30 @@ type BurstBubbleConfig = { }; const BUBBLES: readonly BurstBubbleConfig[] = [ - { id: "left-top", x: -BrandSpacing.xxl * 2 - 4, y: -BrandSpacing.md - 4, size: BrandSpacing.sm + 2 }, - { id: "right-top", x: BrandSpacing.xxl * 2 - 2, y: -BrandSpacing.md - 6, size: BrandSpacing.sm + 4 }, - { id: "left-bottom", x: -BrandSpacing.xxl - 2, y: BrandSpacing.lg + 12, size: BrandSpacing.xs + 4 }, - { id: "right-bottom", x: BrandSpacing.xxl - 2, y: BrandSpacing.lg + 10, size: BrandSpacing.sm + 2 }, + { + id: "left-top", + x: -BrandSpacing.xxl * 2 - 4, + y: -BrandSpacing.md - 4, + size: BrandSpacing.sm + 2, + }, + { + id: "right-top", + x: BrandSpacing.xxl * 2 - 2, + y: -BrandSpacing.md - 6, + size: BrandSpacing.sm + 4, + }, + { + id: "left-bottom", + x: -BrandSpacing.xxl - 2, + y: BrandSpacing.lg + 12, + size: BrandSpacing.xs + 4, + }, + { + id: "right-bottom", + x: BrandSpacing.xxl - 2, + y: BrandSpacing.lg + 10, + size: BrandSpacing.sm + 2, + }, { id: "top", x: 0, y: -BrandSpacing.xxl * 2 + 2, size: BrandSpacing.xs + 5 }, ] as const; @@ -47,11 +67,10 @@ function BurstBubble({ color: string; }) { const bubbleStyle = useAnimatedStyle(() => ({ - opacity: 1 - burst.value, transform: [ { translateX: burst.value * x }, { translateY: burst.value * y }, - { scale: 0.5 + burst.value * 0.9 }, + { scale: 0.75 + burst.value * 0.45 }, ], })); @@ -77,7 +96,6 @@ export function KitSuccessBurst({ const { color, background } = useKitTheme(); const badgeScale = useSharedValue(0.7); const ringScale = useSharedValue(0.75); - const ringOpacity = useSharedValue(0); const burst = useSharedValue(0); useEffect(() => { @@ -85,23 +103,19 @@ export function KitSuccessBurst({ withTiming(1.14, { duration: 220 }), withSpring(1, { damping: 11, stiffness: 220 }), ); - ringOpacity.value = withSequence( - withTiming(0.55, { duration: 120 }), - withTiming(0, { duration: 540 }), - ); ringScale.value = withSequence( withTiming(1.35, { duration: 620 }), withTiming(1.45, { duration: 60 }), ); burst.value = withTiming(1, { duration: 760 }); - }, [badgeScale, burst, ringOpacity, ringScale]); + }, [badgeScale, burst, ringScale]); const badgeStyle = useAnimatedStyle(() => ({ transform: [{ scale: badgeScale.value }], })); const ringStyle = useAnimatedStyle(() => ({ - opacity: ringOpacity.value, transform: [{ scale: ringScale.value }], + borderWidth: 2 + burst.value, })); return ( diff --git a/src/components/ui/kit/kit-switch.tsx b/src/components/ui/kit/kit-switch.tsx index b325346..630b3f1 100644 --- a/src/components/ui/kit/kit-switch.tsx +++ b/src/components/ui/kit/kit-switch.tsx @@ -33,6 +33,12 @@ export function KitSwitch({ }: KitSwitchProps) { const { interaction } = useKitTheme(); const progress = useSharedValue(value ? 1 : 0); + const pressedTrackColor = value + ? (interaction.switchThumbOn as string) + : (interaction.switchTrackOff as string); + const disabledTrackColor = value + ? (interaction.switchTrackOn as string) + : (interaction.switchTrackOff as string); useEffect(() => { progress.value = withTiming(value ? 1 : 0, { duration: 180 }); @@ -59,8 +65,10 @@ export function KitSwitch({ }} style={({ pressed }) => [ styles.pressable, - disabled ? styles.disabled : null, - pressed ? styles.pressed : null, + { + borderRadius: BrandRadius.button, + backgroundColor: disabled ? disabledTrackColor : pressed ? pressedTrackColor : undefined, + }, ]} > {trailing} : null} {hasError ? ( - + {errorText} ) : helperText ? ( {helperText} diff --git a/src/components/ui/kit/use-kit-theme.ts b/src/components/ui/kit/use-kit-theme.ts index 6ddcff4..9fc5979 100644 --- a/src/components/ui/kit/use-kit-theme.ts +++ b/src/components/ui/kit/use-kit-theme.ts @@ -4,8 +4,6 @@ import type { ColorValue } from "react-native"; import { useBrand } from "@/hooks/use-brand"; import { useThemePreference } from "@/hooks/use-theme-preference"; -const TRANSPARENT = "transparent"; - function resolveStringColor(...colors: unknown[]) { for (const color of colors) { if (typeof color === "string") return color; @@ -117,7 +115,7 @@ export function useKitTheme() { primary: palette.primary, primarySubtle: palette.primarySubtle, dangerSubtle: palette.dangerSubtle, - transparent: TRANSPARENT, + transparent: palette.surface, }, foreground: { primary: palette.onPrimary, @@ -130,7 +128,7 @@ export function useKitTheme() { primary: resolveColorValue(palette.borderStrong, palette.border, palette.border), secondary: resolveColorValue(palette.border, palette.borderStrong, palette.border), highlight: highlightBorder, - transparent: TRANSPARENT, + transparent: palette.border, }, shadow: { primaryLift: primaryLiftShadow, diff --git a/src/components/ui/native-search-field.tsx b/src/components/ui/native-search-field.tsx index bfd2e21..ff53fee 100644 --- a/src/components/ui/native-search-field.tsx +++ b/src/components/ui/native-search-field.tsx @@ -4,9 +4,9 @@ import { type StyleProp, TextInput, type TextInputProps, - View, type ViewStyle, } from "react-native"; +import Animated, { LinearTransition, ReduceMotion } from "react-native-reanimated"; import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; import { useBrand } from "@/hooks/use-brand"; import { useThemePreference } from "@/hooks/use-theme-preference"; @@ -35,6 +35,7 @@ type NativeSearchFieldProps = Omit & { clearAccessibilityLabel?: string; size?: "md" | "sm"; containerStyle?: StyleProp; + animateLayout?: boolean; }; export function NativeSearchField({ @@ -44,6 +45,7 @@ export function NativeSearchField({ clearAccessibilityLabel = "Clear search", size = "md", containerStyle, + animateLayout = false, style, ...rest }: NativeSearchFieldProps) { @@ -54,11 +56,21 @@ export function NativeSearchField({ ? (palette.surfaceElevated as string) : (palette.surfaceAlt as string); const metrics = size === "sm" ? SEARCH_SIZE_SM : SEARCH_SIZE_MD; + const pressedBackgroundColor = + resolvedScheme === "dark" ? (palette.surface as string) : (palette.surfaceElevated as string); + const clearButtonBackground = palette.textMuted as string; return ( - onChangeText("")} hitSlop={8} - style={({ pressed }) => ({ opacity: pressed ? 0.65 : 1 })} + style={({ pressed }) => ({ + borderRadius: BrandRadius.pill, + backgroundColor: pressed ? pressedBackgroundColor : clearButtonBackground, + })} > ) : null} - + ); } diff --git a/src/components/ui/sheet-header-block.tsx b/src/components/ui/sheet-header-block.tsx index 44a60e7..6363261 100644 --- a/src/components/ui/sheet-header-block.tsx +++ b/src/components/ui/sheet-header-block.tsx @@ -29,7 +29,6 @@ export function SheetHeaderBlock({ }: SheetHeaderBlockProps) { const palette = useBrand(); const foregroundColor = tone === "primary" ? palette.onPrimary : palette.text; - const mutedColor = tone === "primary" ? palette.onPrimary : palette.textMuted; const inactiveProgress = tone === "primary" ? palette.primaryPressed : palette.surfaceAlt; const trailingBackgroundColor = trailingTone === "danger" @@ -43,6 +42,13 @@ export function SheetHeaderBlock({ : tone === "primary" ? (palette.onPrimary as string) : (palette.text as string); + const subtitleColor = tone === "primary" ? palette.onPrimary : palette.textMuted; + const pressedTrailingBackgroundColor = + trailingTone === "danger" + ? (palette.danger as string) + : tone === "primary" + ? (palette.primary as string) + : (palette.surfaceElevated as string); return ( @@ -70,7 +76,6 @@ export function SheetHeaderBlock({ width: isCurrent ? 28 : 18, height: 8, backgroundColor: (isActive ? foregroundColor : inactiveProgress) as string, - opacity: isCurrent ? 1 : isActive ? 0.82 : 0.48, }} /> ); @@ -85,9 +90,13 @@ export function SheetHeaderBlock({ accessibilityRole="button" accessibilityLabel={trailingLabel} onPress={onPressTrailing} - style={({ pressed }) => ({ - opacity: pressed ? 0.72 : 1, - })} + style={({ pressed }) => [ + { + borderRadius: BrandSpacing.lg, + borderCurve: "continuous", + backgroundColor: pressed ? pressedTrailingBackgroundColor : trailingBackgroundColor, + }, + ]} > {subtitle ? ( - + {subtitle} ) : null} diff --git a/src/contexts/system-ui-context.tsx b/src/contexts/system-ui-context.tsx index 4932301..6d4b3d6 100644 --- a/src/contexts/system-ui-context.tsx +++ b/src/contexts/system-ui-context.tsx @@ -8,7 +8,7 @@ import { } from "react"; import type { ColorValue } from "react-native"; -export type InsetTone = "app" | "sheet" | "card" | "transparent"; +export type InsetTone = "app" | "sheet" | "card"; type SystemUiContextValue = { topInsetTone: InsetTone; diff --git a/src/i18n/translations/en.ts b/src/i18n/translations/en.ts index 5bf15cf..c819f55 100644 --- a/src/i18n/translations/en.ts +++ b/src/i18n/translations/en.ts @@ -428,6 +428,17 @@ const en = { zoneWaitingLabel: "Zone pending", zonePendingBody: "Search an address or use GPS to resolve the service zone automatically.", commandLabel: "Command", + enterManually: "Enter manually", + backToSearch: "Back to search", + findByZip: "Find by postal code", + fieldCity: "City", + fieldStreet: "Street", + fieldNumber: "Number", + fieldFloor: "Floor", + fieldZipCode: "Postal code", + fieldCityPlaceholder: "e.g. Tel Aviv", + fieldStreetPlaceholder: "e.g. Ibn Gabirol", + zipNotFound: "No addresses found for this postal code", }, calendar: { syncOff: "Off", diff --git a/src/i18n/translations/he.ts b/src/i18n/translations/he.ts index e3b3467..8bc6dc7 100644 --- a/src/i18n/translations/he.ts +++ b/src/i18n/translations/he.ts @@ -398,6 +398,17 @@ const he = { zoneWaitingLabel: "האזור ממתין", zonePendingBody: "חפשו כתובת או השתמשו ב-GPS כדי לפתור את אזור השירות אוטומטית.", commandLabel: "שליטה", + enterManually: "הזנה ידנית", + backToSearch: "חזרה לחיפוש", + findByZip: "חיפוש לפי מיקוד", + fieldCity: "עיר", + fieldStreet: "רחוב", + fieldNumber: "מספר", + fieldFloor: "קומה", + fieldZipCode: "מיקוד", + fieldCityPlaceholder: "לדוגמה תל אביב", + fieldStreetPlaceholder: "לדוגמה אבן גבירול", + zipNotFound: "לא נמצאו כתובות למיקוד זה", }, calendar: { syncOff: "כבוי", diff --git a/src/lib/google-places.ts b/src/lib/google-places.ts index 5e1a9a1..2a518e0 100644 --- a/src/lib/google-places.ts +++ b/src/lib/google-places.ts @@ -22,6 +22,10 @@ export type PlaceCoordinates = { latitude: number; longitude: number; formattedAddress: string; + city?: string; + street?: string; + streetNumber?: string; + postalCode?: string; }; let sessionToken: string | null = null; @@ -61,12 +65,22 @@ async function fetchOsmAutocomplete(input: string): Promise { lon?: string; name?: string; display_name?: string; + address?: { + road?: string; + footway?: string; + path?: string; + house_number?: string; + city?: string; + town?: string; + village?: string; + postcode?: string; + }; }> >( - `${OSM_AUTOCOMPLETE_URL}?format=jsonv2&limit=6&addressdetails=1&q=${encodeURIComponent(input)}`, + `${OSM_AUTOCOMPLETE_URL}?format=jsonv2&limit=6&addressdetails=1&countrycodes=il&q=${encodeURIComponent(input)}`, { headers: { - "Accept-Language": "en", + "Accept-Language": "he,en", }, }, { timeoutMs: OSM_AUTOCOMPLETE_TIMEOUT_MS, retries: 1 }, @@ -81,18 +95,42 @@ async function fetchOsmAutocomplete(input: string): Promise { return null; } + // Extract structured address parts from OSM addressdetails + const addr = item.address; + const city = addr?.city ?? addr?.town ?? addr?.village ?? undefined; + const street = addr?.road ?? addr?.footway ?? addr?.path ?? undefined; + const streetNumber = addr?.house_number ?? undefined; + const postalCode = addr?.postcode ?? undefined; + const placeId = `osm:${item.place_id ?? display}`; - fallbackPlaceCache.set(placeId, { + + // Build a clean secondary text: city + postal code (not the full display_name) + const secondaryParts = [city, postalCode].filter(Boolean); + const secondaryText = secondaryParts.join(", ") || undefined; + + // mainText = street + number, or name, or first part of display + const [firstPart] = display.split(", "); + const mainText = + street && streetNumber + ? `${streetNumber} ${street}` + : (item.name?.trim() ?? firstPart ?? display); + + // Only include optional fields when they have values (exactOptionalPropertyTypes) + const cacheEntry: PlaceCoordinates = { latitude, longitude, formattedAddress: display, - }); + ...(city !== undefined ? { city } : {}), + ...(street !== undefined ? { street } : {}), + ...(streetNumber !== undefined ? { streetNumber } : {}), + ...(postalCode !== undefined ? { postalCode } : {}), + }; + fallbackPlaceCache.set(placeId, cacheEntry); - const [mainText, ...rest] = display.split(", "); return { placeId, - mainText: item.name?.trim() || mainText || display, - secondaryText: rest.join(", "), + mainText, + secondaryText: secondaryText ?? "", fullText: display, }; }) @@ -219,3 +257,72 @@ export async function fetchPlaceCoordinates(placeId: string): Promise { + const cleaned = zipCode.replace(/\s+/g, "").trim(); + if (!cleaned || cleaned.length < 5) { + return []; + } + + const url = `https://nominatim.openstreetmap.org/search?format=jsonv2&limit=8&addressdetails=1&countrycodes=il&postcode=${encodeURIComponent(cleaned)}`; + try { + const data = await fetchJsonWithPolicy< + Array<{ + lat?: string; + lon?: string; + display_name?: string; + address?: { + road?: string; + house_number?: string; + city?: string; + town?: string; + village?: string; + postcode?: string; + }; + }> + >( + url, + { headers: { "Accept-Language": "he,en" } }, + { timeoutMs: OSM_AUTOCOMPLETE_TIMEOUT_MS, retries: 1 }, + ); + + return data + .map((item): ZipCodeResult | null => { + const latitude = Number.parseFloat(item.lat ?? ""); + const longitude = Number.parseFloat(item.lon ?? ""); + const display = item.display_name?.trim(); + if (!Number.isFinite(latitude) || !Number.isFinite(longitude) || !display) { + return null; + } + const addr = item.address; + const city = addr?.city ?? addr?.town ?? addr?.village; + const street = addr?.road; + const streetNumber = addr?.house_number; + const postalCode = addr?.postcode; + return { + latitude, + longitude, + formattedAddress: display, + ...(city !== undefined ? { city } : {}), + ...(street !== undefined ? { street } : {}), + ...(streetNumber !== undefined ? { streetNumber } : {}), + ...(postalCode !== undefined ? { postalCode } : {}), + }; + }) + .filter((item): item is ZipCodeResult => item !== null); + } catch { + return []; + } +} diff --git a/src/lib/location-zone.ts b/src/lib/location-zone.ts index 5f2f727..25498cc 100644 --- a/src/lib/location-zone.ts +++ b/src/lib/location-zone.ts @@ -8,6 +8,10 @@ export type ResolvedLocation = { latitude: number; longitude: number; zoneId: string; + city?: string; + street?: string; + streetNumber?: string; + postalCode?: string; }; export type LocationResolveErrorCode = @@ -41,7 +45,16 @@ type FindZoneIdForCoordinate = (point: { latitude: number; longitude: number }) let locationModulePromise: Promise | null = null; let findZoneIdForCoordinatePromise: Promise | null = null; const addressResolutionCache = new Map(); -const reverseAddressCache = new Map(); +const reverseAddressCache = new Map< + string, + { + formattedAddress: string; + city?: string; + street?: string; + streetNumber?: string; + postalCode?: string; + } +>(); const WEB_GEOCODER_SEARCH_URL = "https://nominatim.openstreetmap.org/search"; const WEB_GEOCODER_REVERSE_URL = "https://nominatim.openstreetmap.org/reverse"; const WEB_GEOCODER_TIMEOUT_MS = 12000; @@ -136,19 +149,31 @@ async function getLocationModule() { async function geocodeAddressOnWeb(address: string): Promise<{ latitude: number; longitude: number; + city?: string; + street?: string; + streetNumber?: string; + postalCode?: string; }> { try { - const url = `${WEB_GEOCODER_SEARCH_URL}?format=jsonv2&limit=1&q=${encodeURIComponent(address)}`; + const url = `${WEB_GEOCODER_SEARCH_URL}?format=jsonv2&limit=1&addressdetails=1&q=${encodeURIComponent(address)}`; const results = await fetchJsonWithPolicy< Array<{ lat?: string; lon?: string; + address?: { + road?: string; + house_number?: string; + city?: string; + town?: string; + village?: string; + postcode?: string; + }; }> >( url, { headers: { - "Accept-Language": "en", + "Accept-Language": "he,en", }, }, { timeoutMs: WEB_GEOCODER_TIMEOUT_MS, retries: 1 }, @@ -163,7 +188,21 @@ async function geocodeAddressOnWeb(address: string): Promise<{ locationMessage("profile.settings.errors.locationAddressNotFound"), ); } - return { latitude, longitude }; + + const addr = first?.address; + const city = addr?.city ?? addr?.town ?? addr?.village; + const street = addr?.road; + const streetNumber = addr?.house_number; + const postalCode = addr?.postcode; + + return { + latitude, + longitude, + ...(city !== undefined ? { city } : {}), + ...(street !== undefined ? { street } : {}), + ...(streetNumber !== undefined ? { streetNumber } : {}), + ...(postalCode !== undefined ? { postalCode } : {}), + }; } catch (error) { if (isFetchTimeout(error)) { throw createLocationError( @@ -181,21 +220,53 @@ async function geocodeAddressOnWeb(address: string): Promise<{ } } -async function reverseGeocodeOnWeb(latitude: number, longitude: number): Promise { +async function reverseGeocodeOnWeb( + latitude: number, + longitude: number, +): Promise<{ + formattedAddress: string; + city?: string; + street?: string; + streetNumber?: string; + postalCode?: string; +}> { try { - const url = `${WEB_GEOCODER_REVERSE_URL}?format=jsonv2&lat=${encodeURIComponent(String(latitude))}&lon=${encodeURIComponent(String(longitude))}`; - const data = await fetchJsonWithPolicy<{ display_name?: string }>( + const url = `${WEB_GEOCODER_REVERSE_URL}?format=jsonv2&lat=${encodeURIComponent(String(latitude))}&lon=${encodeURIComponent(String(longitude))}&addressdetails=1`; + const data = await fetchJsonWithPolicy<{ + display_name?: string; + address?: { + road?: string; + footway?: string; + house_number?: string; + city?: string; + town?: string; + village?: string; + postcode?: string; + }; + }>( url, { headers: { - "Accept-Language": "en", + "Accept-Language": "he,en", }, }, { timeoutMs: WEB_GEOCODER_TIMEOUT_MS, retries: 1 }, ); - return data.display_name?.trim() || `${latitude.toFixed(5)}, ${longitude.toFixed(5)}`; + const addr = data.address; + const city = addr?.city ?? addr?.town ?? addr?.village; + const street = addr?.road ?? addr?.footway; + const streetNumber = addr?.house_number; + const postalCode = addr?.postcode; + return { + formattedAddress: + data.display_name?.trim() || `${latitude.toFixed(5)}, ${longitude.toFixed(5)}`, + ...(city !== undefined ? { city } : {}), + ...(street !== undefined ? { street } : {}), + ...(streetNumber !== undefined ? { streetNumber } : {}), + ...(postalCode !== undefined ? { postalCode } : {}), + }; } catch { - return `${latitude.toFixed(5)}, ${longitude.toFixed(5)}`; + return { formattedAddress: `${latitude.toFixed(5)}, ${longitude.toFixed(5)}` }; } } @@ -270,20 +341,14 @@ function toCoordinateCacheKey(latitude: number, longitude: number) { } function formatAddress(parts: { - name?: string | null; street?: string | null; streetNumber?: string | null; city?: string | null; - subregion?: string | null; - region?: string | null; postalCode?: string | null; }) { - const lineOne = [parts.name, parts.streetNumber, parts.street].filter(Boolean).join(" ").trim(); - const lineTwo = [parts.city, parts.subregion, parts.region, parts.postalCode] - .filter(Boolean) - .join(", ") - .trim(); - return [lineOne, lineTwo].filter(Boolean).join(" | ").trim(); + const streetLine = [parts.streetNumber, parts.street].filter(Boolean).join(" ").trim(); + const cityLine = [parts.city, parts.postalCode].filter(Boolean).join(", ").trim(); + return [streetLine, cityLine].filter(Boolean).join(" | "); } async function ensureForegroundPermission(location: LocationModule) { @@ -383,15 +448,32 @@ export async function resolveAddressToZone(addressInput: string): Promise Date: Tue, 24 Mar 2026 03:38:07 +0200 Subject: [PATCH 34/44] Scale studio map pins with clusters and shell asset --- assets/images/map/studio-pin-shell-cyan.png | Bin 0 -> 16724 bytes src/components/maps/queue-map.native.tsx | 134 ++++++++++++++------ 2 files changed, 96 insertions(+), 38 deletions(-) create mode 100644 assets/images/map/studio-pin-shell-cyan.png diff --git a/assets/images/map/studio-pin-shell-cyan.png b/assets/images/map/studio-pin-shell-cyan.png new file mode 100644 index 0000000000000000000000000000000000000000..7006c9b97448ce2ce16afb6c247e6bbe4b373508 GIT binary patch literal 16724 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
    hidCIlz)Mp8y=>u zMeB-vf(s_GZRp#3L~_Jt+s;^Iz55Ak4Ph*{7+AQmhknu S$_L=_0t}w6elF{r5}E)rPNq%( literal 0 HcmV?d00001 diff --git a/src/components/maps/queue-map.native.tsx b/src/components/maps/queue-map.native.tsx index fbd9921..e282184 100644 --- a/src/components/maps/queue-map.native.tsx +++ b/src/components/maps/queue-map.native.tsx @@ -1,6 +1,7 @@ import { Camera, GeoJSONSource, + type GeoJSONSourceRef, Images, Layer, Map as MapLibreMap, @@ -43,25 +44,24 @@ const ATTRIBUTION_ICON_SIZE = BrandSpacing.sm + BrandSpacing.xs; const LOADING_ICON_SIZE = BrandSpacing.iconContainer + BrandSpacing.sm; const LOADING_ICON_RADIUS = LOADING_ICON_SIZE / 2; const STUDIO_MARKER_MIN_ZOOM = 10; +const STUDIO_CLUSTER_MAX_ZOOM = 12; const STUDIO_PIN_ICON_KEY_PREFIX = "studio-pin:"; +const STUDIO_PIN_SHELL_IMAGE = require("../../../assets/images/map/studio-pin-shell-cyan.png"); type MapLoadState = "loading" | "ready" | "error"; const MAP_LOADING_OVERLAY_DELAY_MS = 180; -function buildStudioPinDataUri({ - accentColor, +function buildStudioFallbackPhotoDataUri({ label, textColor, }: { - accentColor: string; label: string; textColor: string; }) { const safeLabel = label.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); const svg = ` - - - + + ${safeLabel} `.trim(); @@ -120,6 +120,7 @@ export const QueueMap = memo(function QueueMap({ const mapKey = `${resolvedScheme}:${retryNonce}`; const mapRef = useRef(null); + const studioSourceRef = useRef(null); const mapLoadStateRef = useRef("loading"); const cameraRef = useRef<{ setStop: (config: unknown) => void; @@ -132,18 +133,8 @@ export const QueueMap = memo(function QueueMap({ const pinShape = useMemo(() => createPinShape(pin), [pin]); const showStudioMarkers = studios.length > 0 && currentZoom >= STUDIO_MARKER_MIN_ZOOM; const studioMarkerImages = useMemo( - () => - Object.fromEntries( - studios.map((studio) => [ - `${STUDIO_PIN_ICON_KEY_PREFIX}${studio.studioId}`, - buildStudioPinDataUri({ - accentColor: mapPalette.markerAccent, - label: studio.studioName.slice(0, 1).toUpperCase(), - textColor: palette.onPrimary as string, - }), - ]), - ), - [mapPalette.markerAccent, palette.onPrimary, studios], + () => ({ [`${STUDIO_PIN_ICON_KEY_PREFIX}shell`]: STUDIO_PIN_SHELL_IMAGE }), + [], ); const studioMarkerSource = useMemo( () => ({ @@ -156,10 +147,12 @@ export const QueueMap = memo(function QueueMap({ }, properties: { studioId: studio.studioId, - iconKey: `${STUDIO_PIN_ICON_KEY_PREFIX}${studio.studioId}`, + iconKey: `${STUDIO_PIN_ICON_KEY_PREFIX}shell`, ...(studio.logoImageUrl ? { photoIconKey: `${STUDIO_PIN_ICON_KEY_PREFIX}${studio.studioId}:photo` } - : {}), + : { + photoIconKey: `${STUDIO_PIN_ICON_KEY_PREFIX}${studio.studioId}:fallback`, + }), }, })), }), @@ -171,10 +164,18 @@ export const QueueMap = memo(function QueueMap({ studios.flatMap((studio) => studio.logoImageUrl ? [[`${STUDIO_PIN_ICON_KEY_PREFIX}${studio.studioId}:photo`, studio.logoImageUrl]] - : [], + : [ + [ + `${STUDIO_PIN_ICON_KEY_PREFIX}${studio.studioId}:fallback`, + buildStudioFallbackPhotoDataUri({ + label: studio.studioName.slice(0, 1).toUpperCase(), + textColor: palette.text as string, + }), + ], + ], ), ), - [studios], + [palette.text, studios], ); const handleRetry = useCallback(() => { setBaseMapStyle(null); @@ -406,33 +407,91 @@ export const QueueMap = memo(function QueueMap({ ) : null} {showStudioMarkers ? ( { const native = event?.nativeEvent ?? event; - const studioId = native?.features?.[0]?.properties?.studioId; + const feature = native?.features?.[0]; + const clusterId = feature?.properties?.cluster_id; + if (typeof clusterId === "number") { + void studioSourceRef.current?.getClusterExpansionZoom(clusterId).then((zoom) => { + const coordinates = feature?.geometry?.coordinates; + if (!Array.isArray(coordinates) || coordinates.length < 2) return; + cameraRef.current?.flyTo({ + center: [coordinates[0], coordinates[1]], + zoom, + duration: 280, + }); + }); + return; + } + const studioId = feature?.properties?.studioId; if (typeof studioId === "string") { onPressStudio?.(studioId); } }} > + + Date: Tue, 24 Mar 2026 03:42:57 +0200 Subject: [PATCH 35/44] Simplify studio map pins and add tint support --- assets/images/map/studio-pin-shell-sdf.png | Bin 0 -> 7152 bytes convex/schema.ts | 1 + convex/users.ts | 17 ++++ src/components/maps/queue-map.native.tsx | 112 ++++++++------------- src/components/maps/queue-map.types.ts | 1 + 5 files changed, 63 insertions(+), 68 deletions(-) create mode 100644 assets/images/map/studio-pin-shell-sdf.png diff --git a/assets/images/map/studio-pin-shell-sdf.png b/assets/images/map/studio-pin-shell-sdf.png new file mode 100644 index 0000000000000000000000000000000000000000..8f1773ad8418a9406a193821e1054289275bd7b1 GIT binary patch literal 7152 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 Se17=;!qQaJQLR?C`uabFeG**& literal 0 HcmV?d00001 diff --git a/convex/schema.ts b/convex/schema.ts index 649021c..1a5f22c 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -250,6 +250,7 @@ export default defineSchema({ latitude: v.optional(v.number()), longitude: v.optional(v.number()), contactPhone: v.optional(v.string()), + mapMarkerColor: v.optional(v.string()), expoPushToken: v.optional(v.string()), notificationsEnabled: v.optional(v.boolean()), logoStorageId: v.optional(v.id("_storage")), diff --git a/convex/users.ts b/convex/users.ts index 204cf46..2b1d441 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -55,6 +55,16 @@ function normalizeEmail(email: string | undefined): string | undefined { return value.length > 0 ? value : undefined; } +function normalizeOptionalMapMarkerColor(value: string | undefined) { + if (!value) return undefined; + const normalized = value.trim().toUpperCase(); + if (normalized.length === 0) return undefined; + if (!/^#[0-9A-F]{6}$/.test(normalized)) { + throw new ConvexError("Map marker color must be a 6-digit hex color"); + } + return normalized; +} + function createUploadSessionToken(userId: Doc<"users">["_id"], now: number) { const entropy = Math.random().toString(36).slice(2, 12); return `${String(userId)}:${now}:${entropy}`; @@ -735,6 +745,7 @@ export const getMyStudioSettings = query({ socialLinks: v.optional(socialLinksValidator), autoExpireMinutesBefore: v.number(), autoAcceptDefault: v.optional(v.boolean()), + mapMarkerColor: v.optional(v.string()), sports: v.array(v.string()), calendarProvider: v.union(v.literal("none"), v.literal("google"), v.literal("apple")), calendarSyncEnabled: v.boolean(), @@ -775,6 +786,7 @@ export const getMyStudioSettings = query({ contactPhone: profile.contactPhone, profileImageUrl, socialLinks: toOptionalSocialLinksPayload(profile.socialLinks), + mapMarkerColor: profile.mapMarkerColor, addressCity: profile.addressCity, addressStreet: profile.addressStreet, addressNumber: profile.addressNumber, @@ -806,6 +818,7 @@ export const getInstructorMapStudios = query({ longitude: v.number(), address: v.optional(v.string()), logoImageUrl: v.optional(v.string()), + mapMarkerColor: v.optional(v.string()), }), ), handler: async (ctx) => { @@ -852,6 +865,7 @@ export const getInstructorMapStudios = query({ ...omitUndefined({ address: studio.address, logoImageUrl: logoUrls[index] ?? undefined, + mapMarkerColor: studio.mapMarkerColor, }), })); }, @@ -907,6 +921,7 @@ export const updateMyStudioSettings = mutation({ longitude: v.optional(v.number()), autoExpireMinutesBefore: v.optional(v.number()), autoAcceptDefault: v.optional(v.boolean()), + mapMarkerColor: v.optional(v.string()), sports: v.optional(v.array(v.string())), }, returns: v.object({ @@ -952,6 +967,7 @@ export const updateMyStudioSettings = mutation({ longitude: args.longitude, }), ); + const mapMarkerColor = normalizeOptionalMapMarkerColor(args.mapMarkerColor); let autoExpireMinutesBefore: number | undefined; if (args.autoExpireMinutesBefore !== undefined) { @@ -976,6 +992,7 @@ export const updateMyStudioSettings = mutation({ contactPhone, latitude, longitude, + mapMarkerColor, autoExpireMinutesBefore, autoAcceptDefault: args.autoAcceptDefault, addressCity, diff --git a/src/components/maps/queue-map.native.tsx b/src/components/maps/queue-map.native.tsx index e282184..6d02196 100644 --- a/src/components/maps/queue-map.native.tsx +++ b/src/components/maps/queue-map.native.tsx @@ -46,28 +46,12 @@ const LOADING_ICON_RADIUS = LOADING_ICON_SIZE / 2; const STUDIO_MARKER_MIN_ZOOM = 10; const STUDIO_CLUSTER_MAX_ZOOM = 12; const STUDIO_PIN_ICON_KEY_PREFIX = "studio-pin:"; -const STUDIO_PIN_SHELL_IMAGE = require("../../../assets/images/map/studio-pin-shell-cyan.png"); +const STUDIO_PIN_LABEL_MIN_ZOOM = 13.25; +const STUDIO_PIN_SHELL_IMAGE = require("../../../assets/images/map/studio-pin-shell-sdf.png"); type MapLoadState = "loading" | "ready" | "error"; const MAP_LOADING_OVERLAY_DELAY_MS = 180; -function buildStudioFallbackPhotoDataUri({ - label, - textColor, -}: { - label: string; - textColor: string; -}) { - const safeLabel = label.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); - const svg = ` - - - ${safeLabel} - - `.trim(); - return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; -} - export const QueueMap = memo(function QueueMap({ mode, pin, @@ -133,7 +117,9 @@ export const QueueMap = memo(function QueueMap({ const pinShape = useMemo(() => createPinShape(pin), [pin]); const showStudioMarkers = studios.length > 0 && currentZoom >= STUDIO_MARKER_MIN_ZOOM; const studioMarkerImages = useMemo( - () => ({ [`${STUDIO_PIN_ICON_KEY_PREFIX}shell`]: STUDIO_PIN_SHELL_IMAGE }), + () => ({ + [`${STUDIO_PIN_ICON_KEY_PREFIX}shell`]: { source: STUDIO_PIN_SHELL_IMAGE, sdf: true }, + }), [], ); const studioMarkerSource = useMemo( @@ -147,36 +133,14 @@ export const QueueMap = memo(function QueueMap({ }, properties: { studioId: studio.studioId, + studioName: studio.studioName, iconKey: `${STUDIO_PIN_ICON_KEY_PREFIX}shell`, - ...(studio.logoImageUrl - ? { photoIconKey: `${STUDIO_PIN_ICON_KEY_PREFIX}${studio.studioId}:photo` } - : { - photoIconKey: `${STUDIO_PIN_ICON_KEY_PREFIX}${studio.studioId}:fallback`, - }), + ...(studio.mapMarkerColor ? { markerColor: studio.mapMarkerColor } : {}), }, })), }), [studios], ); - const studioPhotoImages = useMemo( - () => - Object.fromEntries( - studios.flatMap((studio) => - studio.logoImageUrl - ? [[`${STUDIO_PIN_ICON_KEY_PREFIX}${studio.studioId}:photo`, studio.logoImageUrl]] - : [ - [ - `${STUDIO_PIN_ICON_KEY_PREFIX}${studio.studioId}:fallback`, - buildStudioFallbackPhotoDataUri({ - label: studio.studioName.slice(0, 1).toUpperCase(), - textColor: palette.text as string, - }), - ], - ], - ), - ), - [palette.text, studios], - ); const handleRetry = useCallback(() => { setBaseMapStyle(null); setMapErrorMessage(null); @@ -402,9 +366,7 @@ export const QueueMap = memo(function QueueMap({ onPressZone={onPressZone} /> - {showStudioMarkers ? ( - - ) : null} + {showStudioMarkers ? : null} {showStudioMarkers ? ( ) : null} diff --git a/src/components/maps/queue-map.types.ts b/src/components/maps/queue-map.types.ts index 1ce7047..195740d 100644 --- a/src/components/maps/queue-map.types.ts +++ b/src/components/maps/queue-map.types.ts @@ -13,6 +13,7 @@ export type StudioMapMarker = { longitude: number; address?: string; logoImageUrl?: string; + mapMarkerColor?: string; }; export type QueueMapMode = "zoneSelect" | "pinDrop"; From b3970cd4b4b13a94fa6d75479f4e48b2de5fcd19 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 24 Mar 2026 03:48:09 +0200 Subject: [PATCH 36/44] Fix studio pin shell transparency --- assets/images/map/studio-pin-shell-sdf.png | Bin 7152 -> 2812 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/assets/images/map/studio-pin-shell-sdf.png b/assets/images/map/studio-pin-shell-sdf.png index 8f1773ad8418a9406a193821e1054289275bd7b1..e46738a9f55d65f952c69f4ae12f6d5e54232b03 100644 GIT binary patch literal 2812 zcmVyQ|+I>3$qrT};^%l-iN5F$zp5}Ikwe}9U{?q2ii)!U{I`T+SMexusE4+G48Rq9 zW>9AO`KpJuif20jzb~Q4hXKq4u%+*YV^bSW0`O>w_TR|cSOvgA0RB@@arg0VKB|Z}yh<8c;jb?^ztbK&6pk&pPWT^yFC$UD&x<3Qg6Od4rvSD>c=Q7J2467c zEfA@X$AY_TZJQ<|eD>n0E3dzoI=a6Q#TEs&cpCwnAK6J-4}c4~Z??YAVI4&Y zE!?ioQNT*R&5M=k0A9+`!BvQq3v)XDG=O~&KmEL@wdvwKM8-Ke9i}7OU62kt0=Pd% zC*MLOd^Tsb|2GckNR4p-ZqHc@eHwwYJAhX#o%{#K=B3IQ0QXw-L&wZ}B2apBwm*+M zy0i)L82q0(UlP$*$|inc>C|$*^;0&n8-Qmm`uZ6HW2$A|ALO7-Wpk!j!rVxghasRt z58IHY*P+#ROG`qT3chFQ#C+e43xGKm{e2U{*#p2@%k^i9+r6?-oWOGi9j@W^f>^G% zc<=GP8y65?u;}p`2)^}t%h2dseK#&ZZZ_z0gVbBn-xwPFCZB&R<=B%SoOIIZl@P`x zOJq-QdssnSVu>UYwHCP4^6j7CyKwGom>VN1(-KgH=Q^|->I+n1%B3(PPy za6MlJDfPG38w@(82x;&9(a^Ysz8e-1e=_L!Cf^Mk&kGXW_xm{)SkJ*F-Z)+Q{1gdW zS)QQLQ}oJoH}HbD6Z82{NNEQ8rY|UIgI0Ln;FZ&SHY!q%G3c6X;Dz^D4k;+qNokgC zEQf9T`j`StL(lzuc%d{49jYxh{NFSm4nVJZJ8^f7zp2=vjA&(S;nV>rbxJ2Fh*5C^ zjjo-bM3}8(+LqA8km82PmTh;d>}+VrI>E^sVxZe{MLfRhAFP6D$QgO_rnq64v#FGs zlASofdkp_>YMjy>lzF5R6WeqEQ*PYovS|y%l%Tp#f&)-Qu;Lbq4DQ6XZ90I-+mb7| z-~rQ@tWw5u9Rb2agahHw7OLh&;Nv^oN;x4aKj+_JZX5n!;%^Jt|MR0q+!1DK8@DMWdv zihondXID$%OS+zK%>gK-s|g(i+_7Ipo3!cx2Uq-?NO%!n7O+3J*^tEe zi$UibeLTcjZ7GZtZOU_x^jHdNz2qY(bfx7ipmLO|V&o&1Gk{9-e_aQ7(ei)6Cpi{~ zO{ZQQz!v^>iUx-^3I*DpTp>)%sRK{|sEK_-N^KedAI~{urP8>qJ3!ut)_S(AY{sdu zQ`rIP&~I3gj@}Jub7(msC96mh9Fe@&g|Yi;N%V=y)=|X^cR1 z#`3eTyn0LCGKBmq=l|(q=qoipBW1qE&+u$K0grq=$2&7VFVH}d@@!wA#$d)y;`MYY zD94f+Rz}8yXk~vCi`J`i6uOyLkl=N92JNiX%4wU8D^Z)agBS zKdcFZkYS5(D1aX|@OiQGlM~rlJN#&i(8WSf&0##~2ein&TyI-0$?db+8mPZRQ5NZaIJJ50f$FGrGRxH__KVyEuP`D=L{VqD>@(lg~jso(*QZ8OKz;76zHsL~0H*5f!BY0^ zK+5tEB4nlj#fBoEM}*Lp^AQ2cl`aD@hsl3NfP$sV(TJQy0+cLO?nMF|EFl$#%Nl+WOt}S8 zPYQJ#5@ifu8E1*g)0!%0(_@D$QJLZU8z_Yo4~OpZt#oPc2-J97mXDsv>)s`2yLiQn;Mk;y9s%D*Vg zECB|~8kR4rb<1&!0Hb9M3FSprNlwVB9Q6mgrf( zJNB(nnWU}wzWp8cUlD#vc&b{c9b6=0&T2}Mq20ddUJ-CUu-Jp~AB?1?Q55U};N=#LzY4%)-G)`C$tV@EzZsRLpc$EXAG`lm#(o@QQ^ooM^FW<39pmtnbb##hA#&Dw;C> zi(D^A35bImD(p1qvG$QtlM`!35)A`5S*;K@7u9fpbA2~nsZSTL-pd+)VG9*iloqd# z%Ot8^98#H9rT9yu6$OmHTwn6U%K==6&t&}78q=3Xvg%HOB!d8^`fi-k-u7_gJGOiJ zd^<6|JIVpR?Yl9GA7*P*PugtZIbX7(D2k#eilQirq9}@@D2k%MG5-fRCZkOfv5=+! O0000$2;H5ow;}Bo%`e2XZM_a=FB;Je(dZ<>FKDGlQ5HDVPTPLYN)<`;J^noJi>bz z)k*g%ABe+N_F3#k88A|C{N z|0lv1Cl@>SFV-$@|BpsOOjbhXe}g!3LLTyC?$T6!Zs@gql;fFBYcxL?sE`pw#`HuZ z@rfo?q7mK`qVz|!iE8~pTgiBDl@vcg)MD|B2U>nqZe?Nx8!6E~|3~GS3Vec%k2n13 zvm%Tg@@HSK@0GFfUO--H9zxL^f>=FU_o_JVkfNdj5NWBRR)UDo5kx5K*#93G10Qd= zq}Z$6`6jwc!~KX`1vvsKX)$EQ3(B(ukuIdfMx6hAG!Vp^F&x%UROil{FV@vglSPBZ z^bP$$*{^~yXI2wjwGOz4Fp{pk@EQC>BYY$%tk+I&nyrkICG79fqXk^*aHWyAWr~c5 z9hGlBlkJZs0`Zt9!ZMXcd!vtt=qQn+k*dLz%Gpw2Y>6zS$9l(N>r+|C# zrf*_65!0!UFdUXRV%zY7x#LH?Q4=CJ`j);pMVE zdp(4jmyu*kHm4#x=e_v)=VM5Z*5+XGWJB~?9=DF9RuCs}xLH&tt*6P)nvDKP0R_L< zI8oE@f)Un4@yD<2o$~q>9(j_h&V^6+>0NWfrNS#wiIOyR_Dbu%95b=<_HOLFz6c9^ z6)mfC0mq!(%xb0Q8;P!a*|vKdW3*njRA`Upjz*n$=^;G+1eHFK$@dgyZTOk$Dj}`~ zZ!9%Bs4G;Te*i^VkTxlBjO7>c(pCkv(+;6Uv#*&c$U_yn`r>(NodwXB!eE7caQzu3 z=g`>;?>GzhDuAHTv#n(2BvDBz*Mo1FzpkNEV-KpP&RP5zLlCcyov>c<=NqIEs0N^m@+$C(dwd%zfX2?8P+ljXSLNTqvVn-RKmJOAlVIltS(0k{CQ9X}`FgN7QWk zB8`+M3>laLqcjekNBD;a=j=XnCFu_UK-x4(HHYWdts@c*w#7rb3aT&sIg$;{bk2&W znSSYm(Fp*D>ckD^FeTUT(S5>yJ z`o_m?<5WxRBa6VyUx;i+!2a(e4`*u5tl7Jz;SSCb9l~nW8P<~&%SJI(gONfxe6N?6 zuEuL=M~oyFB-$hfF=-urfs#jaOHzr{nS9~tu;z}|p= zlX@)B-wYC4gydl4GGoq7Kcw9~TVSr)11907$=-}*or<6Y2-*M8wFcB-|EoBP$>bH6 z)ra?6{BqXn$n(nU7GdSxjb4>|_vHOogB8~_?2msrKBE=Y@4ZRaxw@3(z@F4hMq@!< zl4Q{s=ukrlK&E3B62N@v#VTMfLkyS^p@}-$lVEB1gH<>l4W95gFO=TManQ!;f4;wP zx@u)o^rvtx@ShS}|6DkeZZ{0{wZBn`B5~^^s5vbW2drYlhVGynFbwpODj zvcJo(YS1a|dTTNIr5vt*r`U@uL!`@{nOQ+jC3X`Aa!{>Wzn#-Fb3mZ#3iSqL`LO({4#at*j}V#;JCqJSV|e7apzF8TV-vWlgyg;VQyV zE;le`*>=yYC{zb2mxc9elorng^t?-=^3AUBl?;vYHiD~J%1RRbOQ23lr_xaY%~7`4 zMrc@9HeS`20Qe^z%vj|{_R;5A_dQB>Hn z_0Hqk&Lo3zp?UkUSULQoi?hDQ5M!zo%jG~qTV9+RzI*-Mi5m8hd$THV82#!@G)sz% zDSUZXCr2J~IQ`Ije2^c_mh~haeD^Fcp=lJoZQx~_#*>*JEs+k~T^e$J5bh7zJ1JbG zoV(9rb0{Sl1iZ0y%!v3@ofNC)um{%(#9U==XsouyOs)!(>AVBv&LYEpL~N|l>j6~< z3Q}1%bX(nKK61xA_5B76L9UG%6hIX}8Rpf|6F$`cqZZ#y#i3a}P`48iX5UVD@HzF> zM6uc<1^VMV{ORMyGJg_YTIFTV7GFu#<|gOcRQ3f05+-B*3_?o((Qh>+JfbkNQqfPT z&2q%JyMk|vW`7ax)L=sjj@-EGvq%CEbS6uSM#m!Z6_*op5fiSS;>tVyJ(2~xB=^@M zhI&v2vUvx<0k;_nRUhtl>|dWLna*X&ROiilxCjTc=s~4d8IJ@Ky2<&v*1T|M(fvl9 z9Sk4#;e754yFg=IPa! z?g0_5;@P3aO4@&=A8k#~ZQ)U+s}uKQ@NXc-T{*p5Fh9Ijl~Jg)_FeFk#fvOx`x_B6 zO#BU1H}o|ge#(8dBDv)6*1L`a{b#^|cApuz!)&Ml=+}s8O(;)V`V`}HZ^s)zL+83t zz-JzCtW?c{1PTO+nD#VV$r``q{FM2&ii(l*AKn*>>VU zMPGF(6ZsdGt%o#m`9L1oc>0!$D5H}LOPnx=(Q+xZyQSx~u>3;(3n@obAVR$;&6PZb z-86v0h{o)G9fmjF;iS{ZkJLS%Tz>2@i=jpM+rF^HqbsnWo$dH-6!7D5w%2Uzi*IoD z47|66W;+)XDhL?*sAN)g*{7^)-6(gBB);ADMWH4#;W)Hcvl>az`pENJ*BWhRJ@mKtGDw0V9hO!N6a94f?K)!KaXC|LSD?ONKyaqPE^+7sv0TyIE&f zt%N|BpZ_sWNW9Cr!775Bl{zc4J7Izcs*#nsyXURC0R(WS2C=CHWt)<&HJW9O*x)jq zYUHZr*uHy~UpeVObL)u9mGXy*|2QM7aPYco`MF&tS;;YG{We&Iz8zA;0(#1xL$=yiC=|)TSm4!B@4&k zdZN~8Khd*KJSEjOD0H-?zVot3l}^y2qITM&{TuRNXlnXr%L^vC>F{bE@{*=ES|1&R zK=P!~x6el#1I~1Zet5C4i15ZIul8o^7;u9c<9?ziUk(KNdG7lZI z09^=+jZem?B%5b!-nI*Z&+fQc#40Wql;+52NXC-$a>>L9sETJqd)L$cbBO)xoNj~p zwhZp%MOoQZ4=wdxR|yTRM*aw>l|h0h1Q*e=v>lrQ36`XDgBeqfP4Lp^TbkSV0qKNc za%eAnBOf}gm z*+`Py|)i(wc$`bEqqkf$4f9)`Fb|wH;r{O61z!(U%On&SJg3WI`9n z^26>a=RsBAB>ark5ahy*&~-p)GSin_17;SfM;-d95t1o*jMYeXn+daWV&#s(^A{nLOfi+k?wn*#q+`tH?cSM` zS~zYd_G(}@K9J+wnI_F2dFdYSV5)N2&F$zz}nl$6`f(kl7TIjXYVtcRNl% zDJ5rtCqD0vV??YJ&=wn1_h#21^>4dm3(dQ@^Hscr;Ti*) zWn+7MkttcD1A5eB!>ijtA%)L@< zI2lv@`fQU~VegI9atd)#Z$=x?ns}5IPfa^m;C;xBp7Qta)x9|BXTq;4@g3MEr*-Yp z>huFTN`#Aas^OA!J==$c%NV2IYSg)q`g1PjXpdN5J$TQ8s*b4p9BV^u{r&l~BtjR! zCx6zNqj6qN127~B()sg)+#JnQ)#XOub47q5|E*%aDtN_SZos<}{6~3lspIH*686)$ znS=3C|GJ^!ck>yJ`y$IUm$Jv`!+GA4L*M#OfS67O{za=&c@vNAhWdJ|f}tP5fH4G9 zY5g?&&IIAND#^J_1rZ~@x_f2~MptnU%gW_O$Hgn^)hoQ!Jgi><`uFPiJ&yThm4uzn z*sJ&Wt7+J`@nsgBV_-cfEp?L$$RFYYWs7~A@l8j8=d*YF%-so_6t4C$b%t-@(6E4q zv;eYO!?k-nm!>OfREB@LsCaL#y@OqJf=!f(dw!~6ea$us^1p-C#yO!Rbpl)B2AM#jGs)Rv!~wkj<+(U(pk zR#VR6HBQb1e!nm^B;`=9G}m@3)X>TrN(lx$wvPf+hy(j8tbYXOj+&}U#ZqNV>lY31 zQ<=Kj$|>CO-Cyw24mY;c^L{tJ75La;6;DL@_Aef8#NWJ>9)NKby>~;!&~g|+U6h7D zVB{Sy-2}bNvN`zZ7LZU9Y1a7WN%4KyQQhHL=a3+j72`}-nWI~{HIKQ&jU)UzJY2{S zK10)8scW^(TT4~;nSvRE<8rASp}sk-=JwC648rmn6nae-c|?{CoHQkkz9Yr2;&hkK zE+Zg((;ffI>d!T$>b(EyjBF;1ua&SDVMS zG&mcQ(p+%ESSE&30mIwh=0Fid-h(0oNM5pi{hE=8k<9jgtRos~0PERz;BbCIn`Jh& zY|GabmxU#&h}F<=M?e3~@&{9Af$%DjvQB>SN_aKN2fuPO^dktm7$vEn0kZnpR##Cz z^J`MuGDU2jFqedzFA%aqkVBn1>QmBQ@H*y;krQQKV7Mdk*CDg~0wSC0kj*^~>-sD7 zUJmiBz@Ehw;}=JA&=#x-RF;pPEg(K0dy~-8Vd(Bt3KyKcW*}Q=ei!q|Qicyio<}PA z-skGgI+rDci?AkS_oj|BN%)N@*}#i41)xcu|G~hL_JfmZ&5?y{rr9p))l3_3c4l&A z?|EO`%iRH-?YZ}sx}&H3lX`gtH2j#sNzy-ljJefdi`@BLb7blzeU9}aBX>+nz(WDZ zZmBXs<=}O@-z$hevOv$K;2Y;uq1QL81?VX&#?|r~kFAj5%5~zC?;%e5ryq*oX)#Xw zr{_$hv_{nHqNtSAyI;oC31=ott^Se(`N|81P3`GOFe2p()bkom0&>qU~)? z91Y@Co$%7WwkqfEqr}L$^%194w!0bfcO53Xgn`|GpQKVZ<~yuc z2dsW6_D896txK^Ejv4lE!=~Z`Ln?hXcyX(zu0piWRjsj9ilMg@jF5WU8w~z^GJ7$^ zqe44mzk~AvyAh~gHh?K?_)=`&ecq!d`~reRXY1VmdJFNmi4RJ;fvy+OUoC83MdT-R zf-JDeTrs+e?@Dd}AxlPB4i82PedDWU#BbF=!?H*~nM=kvmSDX2nBSTeHosPn}fA zMnu4Zg4>MF;#Eh*UC1YQ9y`zaj@lnw4f2-XJ0BhcQ~X9)W)OKm%*V(v%4r0wWQlcw zoXw&*lD9UJcVnouvI2dE%_u&gPcJbEy$uJpe=8J9d}K1$882Uz)_$wr!nEifhLF77 z$Bs1Bm>oP9=IJigpR}=f>w`lctl7!Bu0T}S6@j*{$R&X@2Gxmj60WaO1Rfu{h6qSW z0}JZllWK0Ioe8=eG_DS?G6=B0PvOKbxSQJ&l4;}68(T8@>3cG-Z`p#Wo`?A-{LyOy z8(+Kt2T8YK^^17Yd~1PGf#CC=_tF(u#L4JjldBjA1fH>^nn_YLQD;WFxAp{FY#=)C;SpK3SHl1;w+p$c!B|*m-|iHbl!LTlZMbNO4IzA1ftI5 z8Wp>x;NRBo1yqmp`h5k}^fT`qAHVWt`gpwSs15|QXaywEoBtUO7Lz&s=yC!>#*1#w z{pR?javHn$N9FXppP`8)VREw7W#QySWwI&exXXv-wp4m^Sc~r89gF?D&}Sbd z>Egag!OabUIk7)YO3l+Vj$D3jY;EVp{k$l-sa&oWtxgJ~>0jAlrLf=>Y1)7P*bs3G z=SA6J4mtu`#N=DVOvs6fa9nX#mmbbU1yICQdG16mDgs+&85Bd8m z@*IzKwgLkrhUp>@fc%h)~%LLcyp^ZjHQKaYzc^2MWy(QrUJ>M3w`4+Jg z(#7Kd6G)Z-SNkiM z>?ST(jL%+%Ih}<4G_~IHsFf5XWsMM5ZiXL3&H;k{rS)w?t^XRmv_P21k*$y?Kq6ZF zB>Q;!qj-ge)YY)+IgX6e4IWp|UDfDgzFKTsjq_;u>RcS+y1h@8e#8bZ4EK5KHsyah z?bK2k!_)bJtG$63K_{>!DEEX6JCqW|XQP!r7kB!a`dooX)F|vbsLTTt8_CNuh}!u< zI}W3Ca=l)8bk8Y2>`Ad2>c+9BOjPsUGNS4$%=+u;6A(^hBK!IS2LBtb1BDfJQwE*| Se17=;!qQaJQLR?C`uabFeG**& From 6e473fd3cc903c82e7e70afdc1a87019f4e321ca Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 24 Mar 2026 03:51:54 +0200 Subject: [PATCH 37/44] Refine map styling and pin thresholds --- .../maps/queue-map.native.helpers.ts | 19 +++++- src/components/maps/queue-map.native.tsx | 24 ++++---- src/constants/brand.ts | 60 +++++++++---------- 3 files changed, 59 insertions(+), 44 deletions(-) diff --git a/src/components/maps/queue-map.native.helpers.ts b/src/components/maps/queue-map.native.helpers.ts index 27cf6f3..6e9bd23 100644 --- a/src/components/maps/queue-map.native.helpers.ts +++ b/src/components/maps/queue-map.native.helpers.ts @@ -71,6 +71,18 @@ function isLowValueSymbolLayer(layer: AnyStyleLayer) { ); } +function is3DBuildingLayer(layer: AnyStyleLayer) { + const id = String(layer?.id ?? "").toLowerCase(); + const sourceLayer = String(layer?.["source-layer"] ?? "").toLowerCase(); + const type = String(layer?.type ?? "").toLowerCase(); + return ( + type === "fill-extrusion" || + id.includes("extrusion") || + id.includes("3d-building") || + sourceLayer.includes("building-3d") + ); +} + export function withMapPersonality( style: AnyStyleSpec, palette: ReturnType, @@ -78,6 +90,7 @@ export function withMapPersonality( ) { const layers = (style.layers ?? []) .filter((layer) => !isRoadNumberLayer(layer)) + .filter((layer) => !is3DBuildingLayer(layer)) .filter((layer) => (showBaseLabels ? true : String(layer?.type ?? "") !== "symbol")) .filter((layer) => !isLowValueSymbolLayer(layer)) .map((layer) => { @@ -113,15 +126,17 @@ export function withMapPersonality( } if (sourceLayer.includes("road") && layerType === "line") { paint["line-color"] = palette.roadLine; - paint["line-opacity"] = 0.92; + paint["line-opacity"] = 0.72; } if ((sourceLayer.includes("building") || id.includes("building")) && layerType === "fill") { paint["fill-color"] = palette.buildingFill; + paint["fill-opacity"] = 0.68; } if (layerType === "symbol") { paint["text-color"] = palette.text; paint["text-halo-color"] = palette.textHalo; - paint["text-halo-width"] = 0.8; + paint["text-halo-width"] = 0.55; + paint["text-opacity"] = 0.9; } nextLayer.paint = paint; diff --git a/src/components/maps/queue-map.native.tsx b/src/components/maps/queue-map.native.tsx index 6d02196..fcd372a 100644 --- a/src/components/maps/queue-map.native.tsx +++ b/src/components/maps/queue-map.native.tsx @@ -43,10 +43,10 @@ const ATTRIBUTION_SIZE = BrandSpacing.iconContainer - BrandSpacing.xs; const ATTRIBUTION_ICON_SIZE = BrandSpacing.sm + BrandSpacing.xs; const LOADING_ICON_SIZE = BrandSpacing.iconContainer + BrandSpacing.sm; const LOADING_ICON_RADIUS = LOADING_ICON_SIZE / 2; -const STUDIO_MARKER_MIN_ZOOM = 10; -const STUDIO_CLUSTER_MAX_ZOOM = 12; +const STUDIO_MARKER_MIN_ZOOM = 11.75; +const STUDIO_CLUSTER_MAX_ZOOM = 13; const STUDIO_PIN_ICON_KEY_PREFIX = "studio-pin:"; -const STUDIO_PIN_LABEL_MIN_ZOOM = 13.25; +const STUDIO_PIN_LABEL_MIN_ZOOM = 14.2; const STUDIO_PIN_SHELL_IMAGE = require("../../../assets/images/map/studio-pin-shell-sdf.png"); type MapLoadState = "loading" | "ready" | "error"; @@ -448,11 +448,11 @@ export const QueueMap = memo(function QueueMap({ ["linear"], ["zoom"], STUDIO_MARKER_MIN_ZOOM, - 0.8, + 0.28, 13.5, - 0.86, + 0.34, 16, - 0.92, + 0.42, ] as any, "icon-allow-overlap": true, "icon-ignore-placement": true, @@ -474,13 +474,13 @@ export const QueueMap = memo(function QueueMap({ ["linear"], ["zoom"], STUDIO_PIN_LABEL_MIN_ZOOM, - 11, + 10, 16, - 13, + 12, ] as any, "text-anchor": "bottom", - "text-offset": [0, -2.05] as any, - "text-max-width": 10 as any, + "text-offset": [0, -1.45] as any, + "text-max-width": 8 as any, "text-allow-overlap": false, }} paint={{ @@ -493,8 +493,8 @@ export const QueueMap = memo(function QueueMap({ ["zoom"], STUDIO_PIN_LABEL_MIN_ZOOM, 0, - 14, - 0.92, + 14.8, + 0.82, ] as any, }} /> diff --git a/src/constants/brand.ts b/src/constants/brand.ts index 698fedb..d331025 100644 --- a/src/constants/brand.ts +++ b/src/constants/brand.ts @@ -161,46 +161,46 @@ const ExplicitBrandPalette: Record = { const NativeMapBrandPalette = { light: { - styleBackground: "#E8F0E7", - waterFill: "#B9D7F2", - waterLine: "#88B9E8", - landcover: "#D5E8C8", - roadLine: "#FFFFFF", - buildingFill: "#E3DDD4", - zoneOutline: "#8F9987", + styleBackground: "#F3F2EE", + waterFill: "#B8D9F6", + waterLine: "#95C4EA", + landcover: "#E6EDDF", + roadLine: "#FCFBF8", + buildingFill: "#E8E3DA", + zoneOutline: "#9BA28E", zoneOutlineOpacity: 0.28, - previewFill: "#A8C8A0", + previewFill: "#D8EDC6", previewFillOpacity: 0.14, - previewOutline: "#7A8D74", + previewOutline: "#9EBB6A", previewOutlineOpacity: 0.42, - selectedOutline: "#7C3AED", + selectedOutline: "#8FBF3C", selectedOutlineOpacity: 1.0, - surfaceAlt: "#F7FBF4", - primary: "#7C3AED", // Vibrant purple - markerAccent: "#19B5FF", - text: "#182018", - textHalo: "#F7FBF4", + surfaceAlt: "#FBFAF7", + primary: "#8FBF3C", + markerAccent: "#2AA8E8", + text: "#20252A", + textHalo: "#FBFAF7", }, dark: { - styleBackground: "#0E1412", - waterFill: "#14344D", - waterLine: "#22567D", - landcover: "#18241B", - roadLine: "#313942", - buildingFill: "#22272B", - zoneOutline: "#4C5A50", + styleBackground: "#11161B", + waterFill: "#173A55", + waterLine: "#2A658D", + landcover: "#182128", + roadLine: "#3A434D", + buildingFill: "#262B31", + zoneOutline: "#55606A", zoneOutlineOpacity: 0.38, - previewFill: "#213126", + previewFill: "#243125", previewFillOpacity: 0.16, - previewOutline: "#657868", + previewOutline: "#8CAF5A", previewOutlineOpacity: 0.56, - selectedOutline: "#A78BFA", + selectedOutline: "#A5CF5A", selectedOutlineOpacity: 1.0, - surfaceAlt: "#141C17", - primary: "#A78BFA", // Vibrant light purple - markerAccent: "#5FD6FF", - text: "#EEF3EA", - textHalo: "#0E1412", + surfaceAlt: "#171D23", + primary: "#A5CF5A", + markerAccent: "#59C6F6", + text: "#EEF3F7", + textHalo: "#171D23", }, } as const; From 549fdac7f07506dd423bbff191fbb1b3a78bdeff Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 24 Mar 2026 03:53:41 +0200 Subject: [PATCH 38/44] Normalize map fonts and neutralize basemap tones --- .../maps/queue-map.native.helpers.ts | 31 +++++++++++-- src/constants/brand.ts | 44 +++++++++---------- 2 files changed, 49 insertions(+), 26 deletions(-) diff --git a/src/components/maps/queue-map.native.helpers.ts b/src/components/maps/queue-map.native.helpers.ts index 6e9bd23..c8cb84d 100644 --- a/src/components/maps/queue-map.native.helpers.ts +++ b/src/components/maps/queue-map.native.helpers.ts @@ -83,6 +83,22 @@ function is3DBuildingLayer(layer: AnyStyleLayer) { ); } +function isRoadLayer(id: string, sourceLayer: string) { + const value = `${id} ${sourceLayer}`; + return ( + value.includes("road") || + value.includes("street") || + value.includes("highway") || + value.includes("motorway") || + value.includes("trunk") || + value.includes("primary") || + value.includes("secondary") || + value.includes("tertiary") || + value.includes("bridge") || + value.includes("tunnel") + ); +} + export function withMapPersonality( style: AnyStyleSpec, palette: ReturnType, @@ -98,6 +114,7 @@ export function withMapPersonality( const id = String(nextLayer.id ?? "").toLowerCase(); const sourceLayer = String(nextLayer["source-layer"] ?? "").toLowerCase(); const paint = { ...(nextLayer.paint ?? {}) }; + const layout = { ...(nextLayer.layout ?? {}) }; const layerType = String(nextLayer.type ?? ""); if (layerType === "background") { @@ -124,22 +141,28 @@ export function withMapPersonality( ) { paint["fill-color"] = palette.landcover; } - if (sourceLayer.includes("road") && layerType === "line") { + if (isRoadLayer(id, sourceLayer) && layerType === "line") { paint["line-color"] = palette.roadLine; - paint["line-opacity"] = 0.72; + paint["line-opacity"] = 0.62; + } + if (isRoadLayer(id, sourceLayer) && layerType === "fill") { + paint["fill-color"] = palette.roadLine; + paint["fill-opacity"] = 0.5; } if ((sourceLayer.includes("building") || id.includes("building")) && layerType === "fill") { paint["fill-color"] = palette.buildingFill; - paint["fill-opacity"] = 0.68; + paint["fill-opacity"] = 0.52; } if (layerType === "symbol") { + layout["text-font"] = ["Noto Sans Regular"]; paint["text-color"] = palette.text; paint["text-halo-color"] = palette.textHalo; paint["text-halo-width"] = 0.55; - paint["text-opacity"] = 0.9; + paint["text-opacity"] = 0.82; } nextLayer.paint = paint; + nextLayer.layout = layout; return nextLayer; }); diff --git a/src/constants/brand.ts b/src/constants/brand.ts index d331025..fcd4ddf 100644 --- a/src/constants/brand.ts +++ b/src/constants/brand.ts @@ -161,46 +161,46 @@ const ExplicitBrandPalette: Record = { const NativeMapBrandPalette = { light: { - styleBackground: "#F3F2EE", - waterFill: "#B8D9F6", - waterLine: "#95C4EA", - landcover: "#E6EDDF", - roadLine: "#FCFBF8", - buildingFill: "#E8E3DA", - zoneOutline: "#9BA28E", + styleBackground: "#F2F3F5", + waterFill: "#B9D8EE", + waterLine: "#8FB9D6", + landcover: "#E6ECE4", + roadLine: "#F8F9FA", + buildingFill: "#DEE2E6", + zoneOutline: "#98A68A", zoneOutlineOpacity: 0.28, - previewFill: "#D8EDC6", + previewFill: "#D5E9C2", previewFillOpacity: 0.14, previewOutline: "#9EBB6A", previewOutlineOpacity: 0.42, selectedOutline: "#8FBF3C", selectedOutlineOpacity: 1.0, - surfaceAlt: "#FBFAF7", + surfaceAlt: "#F7F8FA", primary: "#8FBF3C", markerAccent: "#2AA8E8", - text: "#20252A", - textHalo: "#FBFAF7", + text: "#252A31", + textHalo: "#F7F8FA", }, dark: { - styleBackground: "#11161B", - waterFill: "#173A55", - waterLine: "#2A658D", - landcover: "#182128", - roadLine: "#3A434D", - buildingFill: "#262B31", - zoneOutline: "#55606A", + styleBackground: "#14181D", + waterFill: "#1A3447", + waterLine: "#365B76", + landcover: "#1A2024", + roadLine: "#434A53", + buildingFill: "#2A3036", + zoneOutline: "#5A6870", zoneOutlineOpacity: 0.38, - previewFill: "#243125", + previewFill: "#253224", previewFillOpacity: 0.16, previewOutline: "#8CAF5A", previewOutlineOpacity: 0.56, selectedOutline: "#A5CF5A", selectedOutlineOpacity: 1.0, - surfaceAlt: "#171D23", + surfaceAlt: "#1B2026", primary: "#A5CF5A", markerAccent: "#59C6F6", - text: "#EEF3F7", - textHalo: "#171D23", + text: "#E8EDF2", + textHalo: "#1B2026", }, } as const; From 101e038b9fb318649fdae6393bf2d187cb907977 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 24 Mar 2026 03:56:31 +0200 Subject: [PATCH 39/44] Fix map glyph loading and dark road styling --- .../maps/queue-map.native.helpers.ts | 30 +++++++++++++++---- src/components/maps/queue-map.native.tsx | 14 +++++---- src/constants/brand.ts | 6 ++-- 3 files changed, 36 insertions(+), 14 deletions(-) diff --git a/src/components/maps/queue-map.native.helpers.ts b/src/components/maps/queue-map.native.helpers.ts index c8cb84d..5204f01 100644 --- a/src/components/maps/queue-map.native.helpers.ts +++ b/src/components/maps/queue-map.native.helpers.ts @@ -20,6 +20,7 @@ let offlinePackBootstrapPromise: Promise | null = null; const mapStyleResponseCache = new Map(); const mapStyleResponsePromiseCache = new Map>(); const themedMapStyleCache = new Map(); +const MAPLIBRE_GLYPHS_URL = "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf"; export function sanitizeZoom(value: number, fallback: number) { if (!Number.isFinite(value)) return fallback; @@ -143,22 +144,22 @@ export function withMapPersonality( } if (isRoadLayer(id, sourceLayer) && layerType === "line") { paint["line-color"] = palette.roadLine; - paint["line-opacity"] = 0.62; + paint["line-opacity"] = 1; } if (isRoadLayer(id, sourceLayer) && layerType === "fill") { paint["fill-color"] = palette.roadLine; - paint["fill-opacity"] = 0.5; + paint["fill-opacity"] = 1; } if ((sourceLayer.includes("building") || id.includes("building")) && layerType === "fill") { paint["fill-color"] = palette.buildingFill; - paint["fill-opacity"] = 0.52; + paint["fill-opacity"] = 1; } if (layerType === "symbol") { layout["text-font"] = ["Noto Sans Regular"]; paint["text-color"] = palette.text; paint["text-halo-color"] = palette.textHalo; paint["text-halo-width"] = 0.55; - paint["text-opacity"] = 0.82; + paint["text-opacity"] = 1; } nextLayer.paint = paint; @@ -166,7 +167,26 @@ export function withMapPersonality( return nextLayer; }); - return { ...style, layers }; + return { ...style, glyphs: MAPLIBRE_GLYPHS_URL, layers }; +} + +export function createFallbackMapStyle( + palette: ReturnType, +): AnyStyleSpec { + return { + version: 8, + glyphs: MAPLIBRE_GLYPHS_URL, + sources: {}, + layers: [ + { + id: "queue-map-background", + type: "background", + paint: { + "background-color": palette.styleBackground, + }, + }, + ], + }; } export async function fetchMapStyleSpec(styleUrl: string): Promise { diff --git a/src/components/maps/queue-map.native.tsx b/src/components/maps/queue-map.native.tsx index fcd372a..7615e71 100644 --- a/src/components/maps/queue-map.native.tsx +++ b/src/components/maps/queue-map.native.tsx @@ -24,6 +24,7 @@ import { IconSymbol } from "../ui/icon-symbol"; import { KitSurface } from "../ui/kit"; import { type AnyStyleSpec, + createFallbackMapStyle, createPinShape, createZoneFilter, ensureVectorOfflinePack, @@ -43,10 +44,10 @@ const ATTRIBUTION_SIZE = BrandSpacing.iconContainer - BrandSpacing.xs; const ATTRIBUTION_ICON_SIZE = BrandSpacing.sm + BrandSpacing.xs; const LOADING_ICON_SIZE = BrandSpacing.iconContainer + BrandSpacing.sm; const LOADING_ICON_RADIUS = LOADING_ICON_SIZE / 2; -const STUDIO_MARKER_MIN_ZOOM = 11.75; +const STUDIO_MARKER_MIN_ZOOM = 11.35; const STUDIO_CLUSTER_MAX_ZOOM = 13; const STUDIO_PIN_ICON_KEY_PREFIX = "studio-pin:"; -const STUDIO_PIN_LABEL_MIN_ZOOM = 14.2; +const STUDIO_PIN_LABEL_MIN_ZOOM = 14; const STUDIO_PIN_SHELL_IMAGE = require("../../../assets/images/map/studio-pin-shell-sdf.png"); type MapLoadState = "loading" | "ready" | "error"; @@ -100,7 +101,8 @@ export const QueueMap = memo(function QueueMap({ mode !== "zoneSelect", ); }, [baseMapStyle, mapPalette, mode, themedStyleCacheKey]); - const mapStyle = themedMapStyle ?? preferredStyleUrl; + const fallbackMapStyle = useMemo(() => createFallbackMapStyle(mapPalette), [mapPalette]); + const mapStyle = themedMapStyle ?? fallbackMapStyle; const mapKey = `${resolvedScheme}:${retryNonce}`; const mapRef = useRef(null); @@ -448,11 +450,11 @@ export const QueueMap = memo(function QueueMap({ ["linear"], ["zoom"], STUDIO_MARKER_MIN_ZOOM, - 0.28, + 0.24, 13.5, - 0.34, + 0.31, 16, - 0.42, + 0.39, ] as any, "icon-allow-overlap": true, "icon-ignore-placement": true, diff --git a/src/constants/brand.ts b/src/constants/brand.ts index fcd4ddf..9a7569f 100644 --- a/src/constants/brand.ts +++ b/src/constants/brand.ts @@ -165,7 +165,7 @@ const NativeMapBrandPalette = { waterFill: "#B9D8EE", waterLine: "#8FB9D6", landcover: "#E6ECE4", - roadLine: "#F8F9FA", + roadLine: "#F1F3F5", buildingFill: "#DEE2E6", zoneOutline: "#98A68A", zoneOutlineOpacity: 0.28, @@ -186,8 +186,8 @@ const NativeMapBrandPalette = { waterFill: "#1A3447", waterLine: "#365B76", landcover: "#1A2024", - roadLine: "#434A53", - buildingFill: "#2A3036", + roadLine: "#2A3138", + buildingFill: "#20262C", zoneOutline: "#5A6870", zoneOutlineOpacity: 0.38, previewFill: "#253224", From ec47bcb639fb962abb2d86e555da6b8ae31f0143 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 24 Mar 2026 04:02:21 +0200 Subject: [PATCH 40/44] Tune map road hierarchy and smoothness --- .../maps/queue-map.native.helpers.ts | 61 ++++++++++++++++++- src/constants/brand.ts | 28 +++++---- 2 files changed, 76 insertions(+), 13 deletions(-) diff --git a/src/components/maps/queue-map.native.helpers.ts b/src/components/maps/queue-map.native.helpers.ts index 5204f01..684b69b 100644 --- a/src/components/maps/queue-map.native.helpers.ts +++ b/src/components/maps/queue-map.native.helpers.ts @@ -100,6 +100,42 @@ function isRoadLayer(id: string, sourceLayer: string) { ); } +function isMainRoadLayer(id: string, sourceLayer: string) { + const value = `${id} ${sourceLayer}`; + return ( + value.includes("motorway") || + value.includes("trunk") || + value.includes("primary") || + value.includes("highway") || + value.includes("major") + ); +} + +function isSecondaryRoadLayer(id: string, sourceLayer: string) { + const value = `${id} ${sourceLayer}`; + return ( + value.includes("secondary") || + value.includes("tertiary") || + value.includes("residential") || + value.includes("service") || + value.includes("street") || + value.includes("unclassified") + ); +} + +function isLocalRoadLayer(id: string, sourceLayer: string) { + const value = `${id} ${sourceLayer}`; + return ( + value.includes("path") || + value.includes("track") || + value.includes("service") || + value.includes("living") || + value.includes("lane") || + value.includes("alley") || + value.includes("minor") + ); +} + export function withMapPersonality( style: AnyStyleSpec, palette: ReturnType, @@ -143,11 +179,32 @@ export function withMapPersonality( paint["fill-color"] = palette.landcover; } if (isRoadLayer(id, sourceLayer) && layerType === "line") { - paint["line-color"] = palette.roadLine; + const mainRoad = isMainRoadLayer(id, sourceLayer); + const secondaryRoad = isSecondaryRoadLayer(id, sourceLayer); + const localRoad = isLocalRoadLayer(id, sourceLayer); + const roadColor = mainRoad + ? palette.roadPrimary + : secondaryRoad + ? palette.roadSecondary + : palette.roadTertiary; + paint["line-color"] = roadColor; + paint["line-width"] = mainRoad + ? ["interpolate", ["linear"], ["zoom"], 6, 0.4, 10, 0.82, 14, 1.7] + : secondaryRoad + ? ["interpolate", ["linear"], ["zoom"], 6, 0.28, 10, 0.58, 14, 1.12] + : localRoad + ? ["interpolate", ["linear"], ["zoom"], 6, 0.18, 10, 0.38, 14, 0.78] + : ["interpolate", ["linear"], ["zoom"], 6, 0.2, 10, 0.42, 14, 0.84]; paint["line-opacity"] = 1; + layout["line-cap"] = "round"; + layout["line-join"] = "round"; } if (isRoadLayer(id, sourceLayer) && layerType === "fill") { - paint["fill-color"] = palette.roadLine; + paint["fill-color"] = isMainRoadLayer(id, sourceLayer) + ? palette.roadPrimary + : isSecondaryRoadLayer(id, sourceLayer) + ? palette.roadSecondary + : palette.roadTertiary; paint["fill-opacity"] = 1; } if ((sourceLayer.includes("building") || id.includes("building")) && layerType === "fill") { diff --git a/src/constants/brand.ts b/src/constants/brand.ts index 9a7569f..d198a52 100644 --- a/src/constants/brand.ts +++ b/src/constants/brand.ts @@ -161,25 +161,28 @@ const ExplicitBrandPalette: Record = { const NativeMapBrandPalette = { light: { - styleBackground: "#F2F3F5", - waterFill: "#B9D8EE", - waterLine: "#8FB9D6", - landcover: "#E6ECE4", - roadLine: "#F1F3F5", - buildingFill: "#DEE2E6", - zoneOutline: "#98A68A", + styleBackground: "#F4F6F8", + waterFill: "#B2D3ED", + waterLine: "#84B2D9", + landcover: "#E1E8DE", + roadLine: "#EEF1F4", + roadPrimary: "#E1E6EC", + roadSecondary: "#CCD3DB", + roadTertiary: "#B8C2CD", + buildingFill: "#D8DDE3", + zoneOutline: "#8E9C84", zoneOutlineOpacity: 0.28, - previewFill: "#D5E9C2", + previewFill: "#CFE5BC", previewFillOpacity: 0.14, - previewOutline: "#9EBB6A", + previewOutline: "#95B85F", previewOutlineOpacity: 0.42, selectedOutline: "#8FBF3C", selectedOutlineOpacity: 1.0, - surfaceAlt: "#F7F8FA", + surfaceAlt: "#F8FAFB", primary: "#8FBF3C", markerAccent: "#2AA8E8", text: "#252A31", - textHalo: "#F7F8FA", + textHalo: "#F8FAFB", }, dark: { styleBackground: "#14181D", @@ -187,6 +190,9 @@ const NativeMapBrandPalette = { waterLine: "#365B76", landcover: "#1A2024", roadLine: "#2A3138", + roadPrimary: "#4B5563", + roadSecondary: "#343B44", + roadTertiary: "#2B3138", buildingFill: "#20262C", zoneOutline: "#5A6870", zoneOutlineOpacity: 0.38, From 03b2dca6cf5085b39fdde5b674fe353e950d86f4 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 24 Mar 2026 04:02:51 +0200 Subject: [PATCH 41/44] Polish jobs header motion and loading screen --- src/components/jobs/instructor-feed.tsx | 26 +++- src/components/loading-screen.tsx | 158 ++++++++++-------------- 2 files changed, 83 insertions(+), 101 deletions(-) diff --git a/src/components/jobs/instructor-feed.tsx b/src/components/jobs/instructor-feed.tsx index 99b0e5e..daa36b5 100644 --- a/src/components/jobs/instructor-feed.tsx +++ b/src/components/jobs/instructor-feed.tsx @@ -5,6 +5,7 @@ import { Redirect, useRouter } from "expo-router"; import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { RefreshControl, StyleSheet, View } from "react-native"; +import Animated, { LinearTransition, ReduceMotion } from "react-native-reanimated"; import { type InstructorArchiveRow, InstructorJobsArchiveSheet, @@ -166,29 +167,41 @@ export function InstructorFeed() { ] as const satisfies readonly KitDisclosureButtonGroupOption<"all" | "24h" | "72h">[], [t], ); + const jobsHeaderLayoutTransition = useMemo( + () => LinearTransition.duration(220).reduceMotion(ReduceMotion.System), + [], + ); const jobsSheetConfig = useMemo( () => ({ stickyHeader: ( - - + - - + + - - + + {applyErrorMessage ? ( { + pulse.value = withRepeat( + withSequence( + withTiming(1.04, { duration: 1200, easing: Easing.out(Easing.exp) }), + withTiming(1, { duration: 1200, easing: Easing.in(Easing.exp) }), + ), + -1, + false, + ); + }, [pulse]); + + const symbolStyle = useAnimatedStyle(() => ({ transform: [{ scale: pulse.value }] })); + if (variant === "launch") { return ( - - - + {showBrandMark ? ( + - - {showBrandMark ? ( - - - - ) : null} - - + ) : null} + + {resolvedTitle ? ( {resolvedTitle} - - {resolvedLabel} - - - - - + ) : null} + + {resolvedLabel} + - + ); } @@ -132,16 +92,24 @@ export function LoadingScreen({ - - - {resolvedLabel} - + + {showBrandMark ? ( + + + + ) : null} + + {resolvedLabel} + + ); } From 674dc184b9720142dc9d8b94f0bd36dd656070cf Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 24 Mar 2026 04:23:07 +0200 Subject: [PATCH 42/44] feat(home): instructor job carousel + studio review carousel with accept/reject - Instructor home: horizontal snap-to-page carousel for available jobs with animated dot indicators (shared JobCarouselDots component). Empty state card when no jobs are available. - Studio home: review queue carousel with per-application Accept/Reject buttons wired to reviewApplication mutation. Empty state when queue is clear. Optimistic update via Convex refetch after mutation. - JobCarouselDots: reusable animated dot component using Reanimated SharedValue + withSpring for smooth active-dot scale/opacity transitions. - i18n: new keys for empty states (instructor + studio review queues). --- .../home/home-tab/home-role-content.tsx | 15 + src/components/home/home-tab/index.tsx | 2 +- .../home/instructor-home-content.tsx | 126 ++++-- src/components/home/job-carousel-dots.tsx | 84 ++++ src/components/home/studio-home-content.tsx | 367 +++++++++++++----- src/i18n/translations/en.ts | 4 + src/i18n/translations/he.ts | 4 + 7 files changed, 481 insertions(+), 121 deletions(-) create mode 100644 src/components/home/job-carousel-dots.tsx diff --git a/src/components/home/home-tab/home-role-content.tsx b/src/components/home/home-tab/home-role-content.tsx index 9ac8d12..15ae0c5 100644 --- a/src/components/home/home-tab/home-role-content.tsx +++ b/src/components/home/home-tab/home-role-content.tsx @@ -1,3 +1,4 @@ +import { useMutation } from "convex/react"; import type { Href } from "expo-router"; import { Redirect, useRouter } from "expo-router"; import type { TFunction } from "i18next"; @@ -6,6 +7,7 @@ import { StudioHomeContent } from "@/components/home/studio-home-content"; import type { InstructorMarketplaceJob } from "@/components/jobs/instructor/instructor-job-card"; import { LoadingScreen } from "@/components/loading-screen"; import type { BrandPalette } from "@/constants/brand"; +import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; import { buildRoleTabRoute, ROLE_TAB_ROUTE_NAMES } from "@/navigation/role-routes"; @@ -13,6 +15,15 @@ const INSTRUCTOR_JOBS_ROUTE = buildRoleTabRoute("instructor", ROLE_TAB_ROUTE_NAM const STUDIO_JOBS_ROUTE = buildRoleTabRoute("studio", ROLE_TAB_ROUTE_NAMES.jobs); const STUDIO_CALENDAR_ROUTE = buildRoleTabRoute("studio", ROLE_TAB_ROUTE_NAMES.calendar); +export type Application = { + applicationId: Id<"jobApplications">; + instructorId: Id<"instructorProfiles">; + instructorName: string; + status: "pending" | "accepted" | "rejected" | "withdrawn"; + appliedAt: number; + message?: string; +}; + export type HomeRoleContentProps = { activeRole: "instructor" | "studio"; homeBodyReady: boolean; @@ -50,6 +61,7 @@ export type HomeRoleContentProps = { endTime: number; pay: number; pendingApplicationsCount: number; + applications?: Application[]; }> | undefined; }; @@ -67,6 +79,8 @@ export function HomeRoleContent({ myStudioJobs, }: HomeRoleContentProps) { const router = useRouter(); + const reviewApplication = useMutation(api.jobs.reviewApplication); + const openInstructorStudio = (studioId: Id<"studioProfiles">, jobId: Id<"jobs">) => { router.push(`/instructor/jobs/studios/${String(studioId)}?jobId=${String(jobId)}` as Href); }; @@ -122,6 +136,7 @@ export function HomeRoleContent({ jobsFilled={jobsFilled} onOpenJobs={() => router.push(STUDIO_JOBS_ROUTE)} onOpenCalendar={() => router.push(STUDIO_CALENDAR_ROUTE)} + reviewApplication={reviewApplication} /> ); } diff --git a/src/components/home/home-tab/index.tsx b/src/components/home/home-tab/index.tsx index 061118b..c1c38cf 100644 --- a/src/components/home/home-tab/index.tsx +++ b/src/components/home/home-tab/index.tsx @@ -49,7 +49,7 @@ export default function HomeScreen() { homeBodyReady && !isAuthLoading && isAuthenticated && currentUser?.role === "studio"; const myStudioJobs = useQuery( - api.jobs.getMyStudioJobs, + api.jobs.getMyStudioJobsWithApplications, canQueryStudio ? { limit: HOME_STUDIO_JOBS_LIMIT } : "skip", ); const availableInstructorJobs = useQuery( diff --git a/src/components/home/instructor-home-content.tsx b/src/components/home/instructor-home-content.tsx index 32a9c9a..93bf39f 100644 --- a/src/components/home/instructor-home-content.tsx +++ b/src/components/home/instructor-home-content.tsx @@ -1,18 +1,24 @@ import type { TFunction } from "i18next"; -import { View } from "react-native"; -import Animated, { FadeInUp } from "react-native-reanimated"; +import { Text, useWindowDimensions, View } from "react-native"; +import Animated, { + FadeInUp, + useAnimatedScrollHandler, + useSharedValue, +} from "react-native-reanimated"; import { HomeAgendaWidget } from "@/components/home/home-agenda-widget"; -import { useHomeDashboardLayout } from "@/components/home/home-dashboard-layout"; +import { HomeSurface, useHomeDashboardLayout } from "@/components/home/home-dashboard-layout"; import { getHomeHeaderScrollTopPadding } from "@/components/home/home-header-sheet"; import { HomeSignalTile } from "@/components/home/home-shared"; +import { JobCarouselDots } from "@/components/home/job-carousel-dots"; import { InstructorJobCard, type InstructorMarketplaceJob, } from "@/components/jobs/instructor/instructor-job-card"; import { useScrollSheetBindings } from "@/components/layout/scroll-sheet-provider"; import { TabScreenScrollView } from "@/components/layout/tab-screen-scroll-view"; +import { IconSymbol } from "@/components/ui/icon-symbol"; import type { BrandPalette } from "@/constants/brand"; -import { BrandRadius, BrandSpacing } from "@/constants/brand"; +import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; import type { Id } from "@/convex/_generated/dataModel"; import { useAppInsets } from "@/hooks/use-app-insets"; @@ -40,6 +46,28 @@ type InstructorHomeContentProps = { onOpenStudio: (studioId: Id<"studioProfiles">, jobId: Id<"jobs">) => void; }; +function InstructorJobsEmptyState({ palette, t }: { palette: BrandPalette; t: TFunction }) { + return ( + + + + + {t("home.instructor.noJobsAvailable")} + + + {t("home.instructor.noJobsHint")} + + + + ); +} + export function InstructorHomeContent({ currencyFormatter, locale, @@ -58,12 +86,23 @@ export function InstructorHomeContent({ const { safeTop } = useAppInsets(); const layout = useHomeDashboardLayout(); const { scrollRef, onScroll } = useScrollSheetBindings(); + const { width: screenWidth } = useWindowDimensions(); + + const cardWidth = screenWidth - BrandSpacing.insetRoomy * 2; + const scrollX = useSharedValue(0); + const scrollHandler = useAnimatedScrollHandler({ + onScroll: (event) => { + scrollX.value = event.contentOffset.x; + }, + }); const availableJobsCount = availableJobs?.length ?? 0; - const visibleAvailableJobs = (availableJobs ?? []).slice(0, 3); + const visibleAvailableJobs = (availableJobs ?? []).slice(0, 4); const earningsLabel = currencyFormatter.format(totalEarningsAgorot / 100); const completionLabel = String(lessonsCompleted); + const hasJobs = visibleAvailableJobs.length > 0; + return ( - {visibleAvailableJobs.length > 0 ? ( - - {visibleAvailableJobs.map((job) => ( - + {hasJobs ? ( + + {/* Dot indicators */} + + + {/* Horizontal carousel */} + 1} > - onOpenStudio(job.studioId, job.jobId)} - onOpenStudio={onOpenStudio} - t={t} - /> - - ))} - - ) : null} + {visibleAvailableJobs.map((job) => ( + + onOpenStudio(job.studioId, job.jobId)} + onOpenStudio={onOpenStudio} + t={t} + /> + + ))} + + + ) : ( + + )} + + {/* Stats tiles */} ; + cardWidth: number; + palette: BrandPalette; +}; + +function Dot({ + index, + scrollX, + cardWidth, + palette, +}: { + index: number; + scrollX: SharedValue; + cardWidth: number; + palette: BrandPalette; +}) { + const isActiveStyle = useAnimatedStyle(() => { + "worklet"; + const page = scrollX.value / cardWidth; + const isActive = Math.round(page) === index; + return { + transform: [ + { + scale: withSpring(isActive ? 1.35 : 1.0, { + damping: 18, + stiffness: 300, + }), + }, + ], + opacity: withSpring(isActive ? 1.0 : 0.35, { + damping: 20, + stiffness: 250, + }), + }; + }); + + return ( + + ); +} + +export function JobCarouselDots({ count, scrollX, cardWidth, palette }: JobCarouselDotsProps) { + if (count <= 1) { + return null; + } + + const dots = []; + for (let i = 0; i < count; i++) { + dots.push(); + } + + return ( + + {dots} + + ); +} diff --git a/src/components/home/studio-home-content.tsx b/src/components/home/studio-home-content.tsx index 1afe697..135d5b5 100644 --- a/src/components/home/studio-home-content.tsx +++ b/src/components/home/studio-home-content.tsx @@ -1,7 +1,11 @@ import type { TFunction } from "i18next"; -import { Pressable, Text, View } from "react-native"; -import Animated, { FadeInUp } from "react-native-reanimated"; - +import { useCallback, useState } from "react"; +import { Pressable, Text, useWindowDimensions, View } from "react-native"; +import Animated, { + FadeInUp, + useAnimatedScrollHandler, + useSharedValue, +} from "react-native-reanimated"; import { HomeSectionHeading, HomeSurface, @@ -9,12 +13,16 @@ import { } from "@/components/home/home-dashboard-layout"; import { getHomeHeaderScrollTopPadding } from "@/components/home/home-header-sheet"; import { HomeSignalTile } from "@/components/home/home-shared"; +import type { Application } from "@/components/home/home-tab/home-role-content"; +import { JobCarouselDots } from "@/components/home/job-carousel-dots"; import { useScrollSheetBindings } from "@/components/layout/scroll-sheet-provider"; import { TabScreenScrollView } from "@/components/layout/tab-screen-scroll-view"; import { ActionButton } from "@/components/ui/action-button"; +import { IconSymbol } from "@/components/ui/icon-symbol"; import type { BrandPalette } from "@/constants/brand"; import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; import { getZoneLabel } from "@/constants/zones"; +import type { Id } from "@/convex/_generated/dataModel"; import { toSportLabel } from "@/convex/constants"; import { useAppInsets } from "@/hooks/use-app-insets"; import { formatDateTime } from "@/lib/jobs-utils"; @@ -28,6 +36,7 @@ type RecentJob = { endTime: number; pay: number; pendingApplicationsCount: number; + applications?: Application[]; }; type StudioHomeContentProps = { @@ -41,8 +50,175 @@ type StudioHomeContentProps = { recentJobs: RecentJob[]; onOpenJobs: () => void; onOpenCalendar: () => void; + reviewApplication: (args: { + applicationId: Id<"jobApplications">; + status: "accepted" | "rejected"; + }) => Promise<{ ok: boolean }>; }; +function ReviewQueueEmptyState({ palette, t }: { palette: BrandPalette; t: TFunction }) { + return ( + + + + + {t("home.studio.noReviewJobs")} + + + {t("home.studio.noReviewJobsHint")} + + + + ); +} + +function ReviewApplicationCard({ + application, + job, + palette, + locale, + zoneLanguage, + t, + onReview, + isReviewing, +}: { + application: Application; + job: RecentJob; + palette: BrandPalette; + locale: string; + zoneLanguage: "en" | "he"; + t: TFunction; + onReview: (status: "accepted" | "rejected") => void; + isReviewing: boolean; +}) { + return ( + + + {/* Header: sport + instructor */} + + + {toSportLabel(job.sport as never)} + + + {application.instructorName} + + + {[formatDateTime(job.startTime, locale), getZoneLabel(job.zone, zoneLanguage)].join( + " · ", + )} + + {application.message ? ( + + "{application.message}" + + ) : null} + + + {/* Pending count badge */} + + + {String(job.pendingApplicationsCount)} + + + {t("home.studio.pendingApplicants")} + + + + {/* Accept / Reject buttons */} + + onReview("accepted")} + style={({ pressed }) => ({ + flex: 1, + paddingVertical: BrandSpacing.insetTight, + paddingHorizontal: BrandSpacing.inset, + borderRadius: BrandRadius.button, + backgroundColor: isReviewing + ? (palette.successSubtle as string) + : pressed + ? (palette.success as string) + : (palette.successSubtle as string), + alignItems: "center", + justifyContent: "center", + opacity: isReviewing ? 0.7 : 1, + })} + > + + {isReviewing ? t("jobsTab.studioFeed.accepting") : t("jobsTab.studioFeed.accept")} + + + onReview("rejected")} + style={({ pressed }) => ({ + flex: 1, + paddingVertical: BrandSpacing.insetTight, + paddingHorizontal: BrandSpacing.inset, + borderRadius: BrandRadius.button, + backgroundColor: isReviewing + ? (palette.dangerSubtle as string) + : pressed + ? (palette.danger as string) + : (palette.dangerSubtle as string), + alignItems: "center", + justifyContent: "center", + opacity: isReviewing ? 0.7 : 1, + })} + > + + {isReviewing ? t("jobsTab.studioFeed.rejecting") : t("jobsTab.studioFeed.reject")} + + + + + + ); +} + export function StudioHomeContent({ locale, openJobs, @@ -54,17 +230,36 @@ export function StudioHomeContent({ recentJobs, onOpenJobs, onOpenCalendar, + reviewApplication, }: StudioHomeContentProps) { const { safeTop } = useAppInsets(); const layout = useHomeDashboardLayout(); const zoneLanguage = locale.toLowerCase().startsWith("he") ? "he" : "en"; - const jobsNeedingReview = recentJobs - .filter((job) => job.pendingApplicationsCount > 0) - .slice(0, 4); const { scrollRef, onScroll } = useScrollSheetBindings(); + const { width: screenWidth } = useWindowDimensions(); + + const cardWidth = screenWidth - BrandSpacing.insetRoomy * 2; + const scrollX = useSharedValue(0); + const scrollHandler = useAnimatedScrollHandler({ + onScroll: (event) => { + scrollX.value = event.contentOffset.x; + }, + }); + + // Flatten all pending applications across all jobs into one list + const pendingApplications: Array<{ application: Application; job: RecentJob }> = []; + for (const job of recentJobs) { + if (job.applications) { + for (const application of job.applications) { + if (application.status === "pending") { + pendingApplications.push({ application, job }); + } + } + } + } const heroTitle = - jobsNeedingReview.length > 0 + pendingApplications.length > 0 ? t("home.studio.needsReview") : t("home.studio.heroActive", { count: openJobs, @@ -72,6 +267,23 @@ export function StudioHomeContent({ const visibleRecentJobs = recentJobs.slice(0, layout.isWideWeb ? 6 : 4); + // Reviewing state — which applicationId is currently being reviewed + const [reviewingId, setReviewingId] = useState | null>(null); + + const handleReview = useCallback( + async (applicationId: Id<"jobApplications">, status: "accepted" | "rejected") => { + setReviewingId(applicationId); + try { + await reviewApplication({ applicationId, status }); + } catch (_err) { + // Error handling could be enhanced with a toast/banner + } finally { + setReviewingId(null); + } + }, + [reviewApplication], + ); + return ( - {jobsNeedingReview.length > 0 + {pendingApplications.length > 0 ? t("home.studio.waitingCount", { count: pendingApplicants, }) @@ -187,12 +399,13 @@ export function StudioHomeContent({ 0 ? "row" : "column", + flexDirection: layout.isWideWeb && pendingApplications.length > 0 ? "row" : "column", alignItems: "stretch", gap: layout.sectionGap, }} > - {jobsNeedingReview.length > 0 ? ( + {/* Review queue carousel */} + {pendingApplications.length > 0 ? ( - - {jobsNeedingReview.map((job, index) => ( - - ({ + + + {/* Dot indicators */} + + + {/* Horizontal carousel */} + 1} + > + {pendingApplications.map(({ application, job }) => ( + - - - - - {t("home.studio.queueEyebrow")} - - - {toSportLabel(job.sport as never)} - - - {[ - formatDateTime(job.startTime, locale), - getZoneLabel(job.zone, zoneLanguage), - ].join(" · ")} - - - - {String(job.pendingApplicationsCount)} - - - - - - ))} - + handleReview(application.applicationId, status)} + isReviewing={reviewingId === application.applicationId} + /> + + ))} + + - ) : null} + ) : ( + + + + + )} + {/* Live board */} 0 ? 220 : 180).duration(320)} + entering={FadeInUp.delay(pendingApplications.length > 0 ? 220 : 180).duration(320)} style={{ - flex: layout.isWideWeb && jobsNeedingReview.length > 0 ? 0.92 : undefined, + flex: layout.isWideWeb && pendingApplications.length > 0 ? 0.92 : undefined, gap: BrandSpacing.stack, }} > diff --git a/src/i18n/translations/en.ts b/src/i18n/translations/en.ts index c819f55..7ae3762 100644 --- a/src/i18n/translations/en.ts +++ b/src/i18n/translations/en.ts @@ -1372,6 +1372,8 @@ const en = { nextSubtitle: "Your next confirmed classes.", noUpcoming: "No upcoming sessions yet. Keep an eye on open matches.", emptySchedule: "The jobs board is live when you want the next one.", + noJobsAvailable: "No jobs available", + noJobsHint: "Check back later for new openings.", }, studio: { title: "Home", @@ -1402,6 +1404,8 @@ const en = { recentTitle: "Recent job posts", noRecent: "No jobs posted yet.", emptyBoard: "Post a shift to start filling your schedule.", + noReviewJobs: "Review queue is clear", + noReviewJobsHint: "All applicants have been reviewed.", }, }, explore: { diff --git a/src/i18n/translations/he.ts b/src/i18n/translations/he.ts index 8bc6dc7..8ef91b9 100644 --- a/src/i18n/translations/he.ts +++ b/src/i18n/translations/he.ts @@ -1316,6 +1316,8 @@ const he = { nextSubtitle: "השיעורים המאושרים הבאים שלך.", noUpcoming: "אין עדיין שיעורים קרובים. שווה לבדוק התאמות פתוחות.", emptySchedule: "לוח המשרות זמין כשתרצו את השיעור הבא.", + noJobsAvailable: "אין משרות זמינות", + noJobsHint: "בדקו שוב מאוחר יותר לפתיחות חדשות.", }, studio: { title: "בית", @@ -1346,6 +1348,8 @@ const he = { recentTitle: "משרות אחרונות", noRecent: "עדיין לא פורסמו משרות.", emptyBoard: 'פרסמו משמרת כדי להתחיל למלא את הלו"ז.', + noReviewJobs: "תור הבדיקה פנוי", + noReviewJobsHint: "כל המועמדים נבדקו.", }, }, explore: { From 6c89c185b7b668dabec7c3e58468f3709804fd3f Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 24 Mar 2026 04:24:40 +0200 Subject: [PATCH 43/44] refactor(profile,layout): clean up profile index and top sheet --- .../instructor/profile/index.tsx | 7 ++-- .../(studio-tabs)/studio/profile/index.tsx | 7 ++-- src/components/layout/global-top-sheet.tsx | 32 ++++++++++++++----- .../profile/profile-subpage-sheet.tsx | 2 +- 4 files changed, 29 insertions(+), 19 deletions(-) diff --git a/src/app/(app)/(instructor-tabs)/instructor/profile/index.tsx b/src/app/(app)/(instructor-tabs)/instructor/profile/index.tsx index 4e7a930..ba6a2e3 100644 --- a/src/app/(app)/(instructor-tabs)/instructor/profile/index.tsx +++ b/src/app/(app)/(instructor-tabs)/instructor/profile/index.tsx @@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next"; import { StyleSheet, useWindowDimensions, View } from "react-native"; import { TabScreenRoot } from "@/components/layout/tab-screen-root"; +import { getTopSheetAvailableHeight } from "@/components/layout/top-sheet.helpers"; import { useGlobalTopSheet } from "@/components/layout/top-sheet-registry"; import { useDeferredTabMount } from "@/components/layout/use-deferred-tab-mount"; import { useMeasuredContentHeight } from "@/components/layout/use-measured-content-height"; @@ -18,10 +19,7 @@ import { ProfileSettingRow, } from "@/components/profile/profile-settings-sections"; import { ProfileIndexScrollView } from "@/components/profile/profile-subpage-sheet"; -import { - ProfileDesktopHeroPanel, - ProfileHeaderSheet, -} from "@/components/profile/profile-tab"; +import { ProfileDesktopHeroPanel, ProfileHeaderSheet } from "@/components/profile/profile-tab"; import { KitSwitch } from "@/components/ui/kit"; import { BrandSpacing } from "@/constants/brand"; import { useUser } from "@/contexts/user-context"; @@ -33,7 +31,6 @@ import { useBrand } from "@/hooks/use-brand"; import { useLayoutBreakpoint } from "@/hooks/use-layout-breakpoint"; import { useThemePreference } from "@/hooks/use-theme-preference"; import { buildRoleTabRoute, ROLE_TAB_ROUTE_NAMES } from "@/navigation/role-routes"; -import { getTopSheetAvailableHeight } from "@/components/layout/top-sheet.helpers"; const ROLE_TRANSLATION_KEYS = { pending: "profile.roles.pending", diff --git a/src/app/(app)/(studio-tabs)/studio/profile/index.tsx b/src/app/(app)/(studio-tabs)/studio/profile/index.tsx index 9fce624..eceb768 100644 --- a/src/app/(app)/(studio-tabs)/studio/profile/index.tsx +++ b/src/app/(app)/(studio-tabs)/studio/profile/index.tsx @@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { StyleSheet, Text, useWindowDimensions, View } from "react-native"; import { TabScreenRoot } from "@/components/layout/tab-screen-root"; +import { getTopSheetAvailableHeight } from "@/components/layout/top-sheet.helpers"; import { useGlobalTopSheet } from "@/components/layout/top-sheet-registry"; import { useDeferredTabMount } from "@/components/layout/use-deferred-tab-mount"; import { useMeasuredContentHeight } from "@/components/layout/use-measured-content-height"; @@ -17,10 +18,7 @@ import { ProfileSettingRow, } from "@/components/profile/profile-settings-sections"; import { ProfileIndexScrollView } from "@/components/profile/profile-subpage-sheet"; -import { - ProfileDesktopHeroPanel, - ProfileHeaderSheet, -} from "@/components/profile/profile-tab"; +import { ProfileDesktopHeroPanel, ProfileHeaderSheet } from "@/components/profile/profile-tab"; import { ThemedText } from "@/components/themed-text"; import { ChoicePill } from "@/components/ui/choice-pill"; import { IconSymbol } from "@/components/ui/icon-symbol"; @@ -37,7 +35,6 @@ import { useThemePreference } from "@/hooks/use-theme-preference"; import { EXPIRY_OVERRIDE_PRESETS } from "@/lib/jobs-utils"; import { omitUndefined } from "@/lib/omit-undefined"; import { buildRoleTabRoute, ROLE_TAB_ROUTE_NAMES } from "@/navigation/role-routes"; -import { getTopSheetAvailableHeight } from "@/components/layout/top-sheet.helpers"; const ROLE_TRANSLATION_KEYS = { pending: "profile.roles.pending", diff --git a/src/components/layout/global-top-sheet.tsx b/src/components/layout/global-top-sheet.tsx index 96da61b..982c990 100644 --- a/src/components/layout/global-top-sheet.tsx +++ b/src/components/layout/global-top-sheet.tsx @@ -1,6 +1,13 @@ import { usePathname } from "expo-router"; import { isValidElement, useCallback, useEffect, useRef } from "react"; -import { Platform, StyleSheet, type StyleProp, useWindowDimensions, View, type ViewStyle } from "react-native"; +import { + Platform, + type StyleProp, + StyleSheet, + useWindowDimensions, + View, + type ViewStyle, +} from "react-native"; import Reanimated, { FadeIn, FadeOut, @@ -19,10 +26,7 @@ import { } from "@/components/layout/top-sheet-registry"; import { useAppInsets } from "@/hooks/use-app-insets"; import { useBrand } from "@/hooks/use-brand"; -import { - getFallbackSheetColors, - resolveTopSheetRouteTab, -} from "./global-top-sheet.helpers"; +import { getFallbackSheetColors, resolveTopSheetRouteTab } from "./global-top-sheet.helpers"; import { getTopSheetStepHeights } from "./top-sheet.helpers"; /** @@ -159,7 +163,11 @@ export function GlobalTopSheet() { if (!node) return null; return ( - + {node} @@ -190,7 +198,11 @@ export function GlobalTopSheet() { stickyFooter={renderTransitionedNode("sticky-footer", richStickyFooter)} revealOnExpand={renderTransitionedNode("reveal", richRevealOnExpand, { flex: 1 })} > - {renderTransitionedNode("children", richChildren, richChildren ? { flex: 1 } : undefined)} + {renderTransitionedNode( + "children", + richChildren, + richChildren ? { flex: 1 } : undefined, + )} {activeConfig.overlay ? ( - {renderTransitionedNode("content", activeConfig.content, activeConfig.content ? { flex: 1 } : undefined)} + {renderTransitionedNode( + "content", + activeConfig.content, + activeConfig.content ? { flex: 1 } : undefined, + )} {activeConfig.overlay ? ( Date: Tue, 24 Mar 2026 04:41:36 +0200 Subject: [PATCH 44/44] fix: silent catch in review carousel, loading-screen animation cleanup, kit-switch pressed color - studio-home-content: surface error text on card when reviewApplication mutation fails instead of silently swallowing the error - loading-screen: add cancelAnimation() cleanup to infinite pulse useEffect to prevent animation leak on unmount - kit-switch: pressedTrackColor now uses switchTrackOn (not switchThumbOn) when switch value is true + pressed --- src/components/home/studio-home-content.tsx | 14 +++++++++++++- src/components/loading-screen.tsx | 4 ++++ src/components/ui/kit/kit-switch.tsx | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/components/home/studio-home-content.tsx b/src/components/home/studio-home-content.tsx index 135d5b5..07afe32 100644 --- a/src/components/home/studio-home-content.tsx +++ b/src/components/home/studio-home-content.tsx @@ -87,6 +87,7 @@ function ReviewApplicationCard({ t, onReview, isReviewing, + hasError, }: { application: Application; job: RecentJob; @@ -96,6 +97,7 @@ function ReviewApplicationCard({ t: TFunction; onReview: (status: "accepted" | "rejected") => void; isReviewing: boolean; + hasError: boolean; }) { return ( @@ -153,6 +155,13 @@ function ReviewApplicationCard({ + {/* Error feedback */} + {hasError ? ( + + {t("common.error")} + + ) : null} + {/* Accept / Reject buttons */} | null>(null); + const [errorId, setErrorId] = useState | null>(null); const handleReview = useCallback( async (applicationId: Id<"jobApplications">, status: "accepted" | "rejected") => { setReviewingId(applicationId); + setErrorId(null); try { await reviewApplication({ applicationId, status }); } catch (_err) { - // Error handling could be enhanced with a toast/banner + setErrorId(applicationId); } finally { setReviewingId(null); } @@ -453,6 +464,7 @@ export function StudioHomeContent({ t={t} onReview={(status) => handleReview(application.applicationId, status)} isReviewing={reviewingId === application.applicationId} + hasError={errorId === application.applicationId} /> ))} diff --git a/src/components/loading-screen.tsx b/src/components/loading-screen.tsx index 506c67b..5686d3b 100644 --- a/src/components/loading-screen.tsx +++ b/src/components/loading-screen.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { View } from "react-native"; import Animated, { + cancelAnimation, Easing, FadeIn, useAnimatedStyle, @@ -44,6 +45,9 @@ export function LoadingScreen({ -1, false, ); + return () => { + cancelAnimation(pulse); + }; }, [pulse]); const symbolStyle = useAnimatedStyle(() => ({ transform: [{ scale: pulse.value }] })); diff --git a/src/components/ui/kit/kit-switch.tsx b/src/components/ui/kit/kit-switch.tsx index 630b3f1..1809241 100644 --- a/src/components/ui/kit/kit-switch.tsx +++ b/src/components/ui/kit/kit-switch.tsx @@ -34,7 +34,7 @@ export function KitSwitch({ const { interaction } = useKitTheme(); const progress = useSharedValue(value ? 1 : 0); const pressedTrackColor = value - ? (interaction.switchThumbOn as string) + ? (interaction.switchTrackOn as string) : (interaction.switchTrackOff as string); const disabledTrackColor = value ? (interaction.switchTrackOn as string)