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/assets/images/map/studio-pin-shell-cyan.png b/assets/images/map/studio-pin-shell-cyan.png new file mode 100644 index 0000000..7006c9b Binary files /dev/null and b/assets/images/map/studio-pin-shell-cyan.png differ 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 0000000..e46738a Binary files /dev/null and b/assets/images/map/studio-pin-shell-sdf.png differ 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 e2b875c..70cee3f 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.3", "react": "19.2.0", "react-dom": "19.2.0", "react-i18next": "^16.5.8", "react-native": "0.83.2", + "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", @@ -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", @@ -73,7 +79,12 @@ }, }, }, + "overrides": { + "lightningcss": "1.30.1", + }, "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 +651,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=="], @@ -666,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=="], @@ -678,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=="], @@ -722,6 +767,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 +869,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 +881,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 +965,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 +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.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-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], + "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-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="], + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="], - "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.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="], - "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="], + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig=="], - "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.30.1", "", { "os": "linux", "cpu": "arm" }, "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q=="], - "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.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw=="], - "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.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ=="], - "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.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw=="], - "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.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ=="], - "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.30.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA=="], - "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.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=="], @@ -1290,6 +1343,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 +1415,8 @@ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "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=="], "nocache": ["nocache@3.0.4", "", {}, "sha512-WDD0bdg9mbq6F4mRxEYcPWwfA1vxd0mrvKOyxI7Xj/atfRHVeutzuWByG//jfm4uPzp0y4Kj051EORCBSQMycw=="], @@ -1446,7 +1503,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 +1553,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@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=="], "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 +1725,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 +1887,8 @@ "@expo/fingerprint/arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], + "@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 +1923,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 +2033,8 @@ "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/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=="], 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/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/schema.ts b/convex/schema.ts index 72c541f..1a5f22c 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,10 +241,16 @@ 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()), 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 daa922e..2b1d441 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, @@ -54,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}`; @@ -432,6 +443,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")), @@ -471,6 +487,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, }), @@ -487,6 +508,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()), @@ -523,6 +549,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, @@ -582,6 +625,11 @@ export const updateMyInstructorSettings = mutation({ ...omitUndefined({ hourlyRateExpectation: args.hourlyRateExpectation, address, + addressCity, + addressStreet, + addressNumber, + addressFloor, + addressPostalCode, latitude, longitude, }), @@ -681,6 +729,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()), @@ -692,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(), @@ -732,6 +786,12 @@ export const getMyStudioSettings = query({ contactPhone: profile.contactPhone, profileImageUrl, socialLinks: toOptionalSocialLinksPayload(profile.socialLinks), + mapMarkerColor: profile.mapMarkerColor, + addressCity: profile.addressCity, + addressStreet: profile.addressStreet, + addressNumber: profile.addressNumber, + addressFloor: profile.addressFloor, + addressPostalCode: profile.addressPostalCode, }), notificationsEnabled, hasExpoPushToken, @@ -747,49 +807,67 @@ export const getMyStudioSettings = query({ }, }); -export const getStudiosWithLocations = query({ +export const getInstructorMapStudios = query({ args: {}, returns: v.array( v.object({ studioId: v.id("studioProfiles"), studioName: v.string(), - address: v.string(), + zone: v.string(), latitude: v.number(), longitude: v.number(), - profileImageUrl: v.optional(v.string()), - sport: v.optional(v.string()), + address: v.optional(v.string()), + logoImageUrl: v.optional(v.string()), + mapMarkerColor: v.optional(v.string()), }), ), handler: async (ctx) => { - const studios = await ctx.db.query("studioProfiles").collect(); - - const studiosWithLocation = await Promise.all( - studios - .filter((s) => typeof s.latitude === "number" && typeof s.longitude === "number") - .map(async (studio) => { - const [logoUrl, sportsRow] = await Promise.all([ - studio.logoStorageId ? ctx.storage.getUrl(studio.logoStorageId) : null, - ctx.db - .query("studioSports") - .withIndex("by_studio_id", (q) => q.eq("studioId", studio._id)) - .first(), - ]); - - return { - studioId: studio._id, - studioName: studio.studioName, - address: studio.address, - latitude: studio.latitude as number, - longitude: studio.longitude as number, - ...omitUndefined({ - profileImageUrl: logoUrl ?? undefined, - sport: sportsRow?.sport, - }), - }; - }), + 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 studiosWithLocation; + 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, + mapMarkerColor: studio.mapMarkerColor, + }), + })); }, }); @@ -832,12 +910,18 @@ 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()), 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({ @@ -854,6 +938,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, @@ -866,6 +967,7 @@ export const updateMyStudioSettings = mutation({ longitude: args.longitude, }), ); + const mapMarkerColor = normalizeOptionalMapMarkerColor(args.mapMarkerColor); let autoExpireMinutesBefore: number | undefined; if (args.autoExpireMinutesBefore !== undefined) { @@ -890,8 +992,14 @@ export const updateMyStudioSettings = mutation({ contactPhone, latitude, longitude, + mapMarkerColor, autoExpireMinutesBefore, autoAcceptDefault: args.autoAcceptDefault, + addressCity, + addressStreet, + addressNumber, + addressFloor, + addressPostalCode, }), updatedAt: Date.now(), }); diff --git a/docs/maplibre-style-workflow.md b/docs/maplibre-style-workflow.md new file mode 100644 index 0000000..997dffe --- /dev/null +++ b/docs/maplibre-style-workflow.md @@ -0,0 +1,39 @@ +# 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). + +Current default direction: + +- light: OpenFreeMap `liberty` +- dark: OpenFreeMap `dark` + +## 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/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 b073d5c..6dd7f4b 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: true, +}); 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..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", @@ -56,6 +57,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 +88,12 @@ "expo-web-browser": "~55.0.10", "geojson": "^0.5.0", "i18next": "^25.9.0", + "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": "3.0.6", "react-native-gesture-handler": "~2.30.0", "react-native-reanimated": "4.2.1", "react-native-safe-area-context": "~5.6.2", @@ -96,7 +101,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 +123,11 @@ ] } }, - "private": true + "private": true, + "resolutions": { + "lightningcss": "1.30.1" + }, + "overrides": { + "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/scripts/android/start-expo-linux.sh b/scripts/android/start-expo-linux.sh index 2aa293d..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" @@ -117,11 +118,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/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/_layout.tsx b/src/app/(app)/(instructor-tabs)/instructor/_layout.tsx index 1e84958..b03076f 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,8 @@ 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; @@ -25,5 +30,44 @@ 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"); + 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") { + 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/app/(app)/(instructor-tabs)/instructor/jobs/studios/[studioId].tsx b/src/app/(app)/(instructor-tabs)/instructor/jobs/studios/[studioId].tsx index 055391d..76522e3 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,8 @@ 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 +100,8 @@ export default function InstructorStudioProfileRoute() { height: headerHeight, justifyContent: "space-between", overflow: "hidden", - borderBottomLeftRadius: 28, - borderBottomRightRadius: 28, + 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) => ( setApplyErrorMessage(null)} - borderColor="transparent" + borderColor={palette.danger} backgroundColor={palette.dangerSubtle} textColor={palette.danger} iconColor={palette.danger} /> ) : 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: 112, - 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: 10, - }, - 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 9334cfb..0998a3e 100644 --- a/src/app/(app)/(instructor-tabs)/instructor/profile/identity-verification.tsx +++ b/src/app/(app)/(instructor-tabs)/instructor/profile/identity-verification.tsx @@ -32,7 +32,7 @@ import { ThemedText } from "@/components/themed-text"; import { IconButton } from "@/components/ui/icon-button"; import { IconSymbol } from "@/components/ui/icon-symbol"; import { KitSuccessBurst } from "@/components/ui/kit"; -import { BrandSpacing } from "@/constants/brand"; +import { BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; import { api } from "@/convex/_generated/api"; import { useBrand } from "@/hooks/use-brand"; import { useThemePreference } from "@/hooks/use-theme-preference"; @@ -168,13 +168,7 @@ function LinkPill({ opacity: pressed ? 0.72 : 1, })} > - + {label} @@ -185,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, ), @@ -199,21 +193,13 @@ function LoaderDot({ delay, color }: { delay: number; color: string }) { }, [delay, pulse]); const animatedStyle = useAnimatedStyle(() => ({ - opacity: pulse.value, transform: [{ scale: 0.8 + pulse.value * 0.35 }], })); return ( ); } @@ -246,52 +232,41 @@ 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 }], })); return ( - + @@ -365,7 +338,7 @@ function VerificationResolvingState({ label }: { label: string }) { {t("profile.identityVerification.resolvingTitle")} @@ -422,9 +395,11 @@ 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 +432,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 +654,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} @@ -693,39 +668,29 @@ export default function IdentityVerificationScreen() { /> } > - + {showApprovalBurst ? : null} - + {t("profile.identityVerification.eyebrow")} - - - + + + {getStatusHeadline(status, t)} - + {getStatusBody(status, t)} @@ -734,11 +699,7 @@ export default function IdentityVerificationScreen() { {legalName ? ( - + {t("profile.identityVerification.verifiedLegalName")} @@ -747,7 +708,7 @@ export default function IdentityVerificationScreen() { ) : null} {verifiedAtLabel || lastEventAtLabel ? ( - + {verifiedAtLabel ? ( {t("profile.identityVerification.verifiedAt", { @@ -773,18 +734,20 @@ 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 - ? (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, })} > @@ -814,22 +777,19 @@ export default function IdentityVerificationScreen() { {t("profile.identityVerification.whyTitle")} - + {t("profile.identityVerification.whyIntro")} - + 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 +285,7 @@ export default function InstructorProfileScreen() { instructorSettings?.socialLinks, instructorSettings?.sports, nameValue, + onProfileHeaderLayout, palette, profileStatus, t, @@ -288,7 +294,9 @@ export default function InstructorProfileScreen() { const profileSheetConfig = useMemo( () => ({ - content: profileSheetContent, + render: () => ({ + children: profileSheetContent, + }), steps: [profileSheetStep], initialStep: 0, padding: { @@ -304,7 +312,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 || @@ -559,7 +571,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 2c8e460..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 { StyleSheet, Text, View } from "react-native"; +import { Pressable, Text, TextInput, View } from "react-native"; import { LoadingScreen } from "@/components/loading-screen"; import { @@ -13,18 +13,21 @@ 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"; 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"; 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; +} + +function FieldLabel({ children }: { children: string }) { + return ( + + {children} + + ); +} - return `${latitude.toFixed(4)}, ${longitude.toFixed(4)}`; +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,293 +389,313 @@ 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 */} + + + + {/* GPS secondary action */} + { + void resolveByGps(); + }} + disabled={locationResolver.isResolving} + palette={palette} + tone="secondary" + fullWidth + /> + + + {/* 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} - - - - - + {/* Secondary actions row */} + + {!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")} + + + )} - - - - - - - {t("profile.location.addressTitle")} - + {manualMode && ( + <> + + { + void handleZipCodeLookup(); + }} + disabled={postalCode.trim().length < 5 || isSearchingZip} + className="flex-row items-center gap-1" + > + - {t("profile.location.addressBody")} + {t("profile.location.findByZip")} - - - + + + )} + - 0 ? ( + + {zipResults.slice(0, 5).map((result) => ( + { - void resolveByGps(); + applyZipResult(result); + setManualMode(false); }} - disabled={locationResolver.isResolving} - palette={palette} - tone="secondary" - fullWidth - /> - - {coordinateLabel ? ( - - - {t("profile.location.coordinatesLabel")} - - - {coordinateLabel} - - - ) : null} - - - - - - - - - ({ + 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 */} ); } - -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 - }, - 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, // 10px - }, -}); diff --git a/src/app/(app)/(instructor-tabs)/instructor/profile/payments.tsx b/src/app/(app)/(instructor-tabs)/instructor/profile/payments.tsx index 2a18681..8f3ab24 100644 --- a/src/app/(app)/(instructor-tabs)/instructor/profile/payments.tsx +++ b/src/app/(app)/(instructor-tabs)/instructor/profile/payments.tsx @@ -4,6 +4,7 @@ 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"; @@ -17,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 { BrandSpacing } 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"; @@ -377,26 +378,24 @@ export default function ProfilePaymentsScreen() { return ( {t("profile.payments.finalizingTitle")} - + {t("profile.payments.finalizingBody")} @@ -408,27 +407,25 @@ export default function ProfilePaymentsScreen() { return ( {t("profile.payments.successTitle")} - + {t("profile.payments.successBody")} @@ -441,63 +438,51 @@ export default function ProfilePaymentsScreen() { - + - + {t("profile.payments.verifyToConnectBankTitle")} {t("profile.payments.verifyToConnectBankBody")} - + {t("profile.identityVerification.providerPill")} @@ -508,18 +493,20 @@ export default function ProfilePaymentsScreen() { setShowVerifyModal(false); void router.push(INSTRUCTOR_IDENTITY_VERIFICATION_ROUTE as Href); }} - style={({ pressed }) => ({ - width: "100%", - paddingVertical: 16, - paddingHorizontal: 18, - borderRadius: 20, - borderCurve: "continuous", - alignItems: "center", - backgroundColor: palette.didit.accent, - opacity: pressed ? 0.85 : 1, - })} + className="w-full border active:opacity-[0.85] rounded-button px-lg py-md" + style={({ pressed }) => + vars({ + "--tw-bg-primary": String(palette.didit.accent), + "--tw-text": String(palette.onPrimary), + opacity: pressed ? 0.85 : 1, + }) + } > - + {t("profile.payments.verifyToConnectBankCta")} @@ -527,13 +514,12 @@ 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 px-md py-sm" > - + {t("common.cancel")} @@ -546,40 +532,35 @@ export default function ProfilePaymentsScreen() { return ( - + {/* Consolidated Error/Info Banner */} {destinationError || withdrawError || preferenceError ? ( - + {destinationError || withdrawError || preferenceError} ) : destinationInfo || withdrawInfo || preferenceInfo ? ( - + {destinationInfo || withdrawInfo || preferenceInfo} @@ -610,23 +591,25 @@ 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: 14, - paddingVertical: 8, - borderRadius: 999, - borderCurve: "continuous", - backgroundColor: pressed ? palette.surfaceAlt : palette.primarySubtle, - borderWidth: 1, - borderColor: palette.primary as string, - })} + 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), + backgroundColor: String(pressed ? palette.surfaceAlt : palette.primarySubtle), + }) + } > - + {t("profile.setup.verifyIdentity")} ) : !payoutSummary?.hasVerifiedDestination ? ( - + {payoutSummary?.onboardingStatus === "pending" ? t("profile.payments.onboardingPending") : payoutSummary?.onboardingStatus === "failed" @@ -637,33 +620,17 @@ export default function ProfilePaymentsScreen() { {/* Hero Balance Card */} - + - - + + {t("profile.payments.available")} @@ -671,16 +638,14 @@ export default function ProfilePaymentsScreen() { numberOfLines={1} minimumFontScale={0.76} adjustsFontSizeToFit - style={{ - color: "#FFF", - fontSize: 40, - lineHeight: 44, - fontWeight: "800", - fontVariant: ["tabular-nums"], - marginTop: 4, - letterSpacing: -1, - flexShrink: 1, - }} + className="mt-xs 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, @@ -690,44 +655,37 @@ export default function ProfilePaymentsScreen() { - + {payoutSummary?.currency ?? "ILS"} - + ({ - flex: 1, - backgroundColor: + className="flex-1 flex-row items-center justify-center overflow-hidden active:scale-[0.985] gap-sm rounded-button px-lg py-md" + style={() => { + 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; + 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(); }} @@ -739,8 +697,11 @@ export default function ProfilePaymentsScreen() { (payoutSummary?.availableAmountAgorot ?? 0) <= 0 } > - - + + {t("profile.payments.withdraw")} @@ -752,30 +713,17 @@ export default function ProfilePaymentsScreen() { ? t("profile.payments.manageBank") : t("profile.payments.connectBank") } - style={({ pressed }) => ({ - flex: 1, - backgroundColor: payoutSummary?.hasVerifiedDestination - ? pressed - ? "rgba(255,255,255,0.2)" - : "rgba(255,255,255,0.14)" - : pressed - ? "rgba(0,0,0,0.88)" - : "#000", - borderRadius: 20, - minHeight: 54, - padding: 14, - alignItems: "center", - borderCurve: "continuous", - flexDirection: "row", - justifyContent: "center", - gap: 8, - overflow: "hidden", - borderWidth: 1, - borderColor: payoutSummary?.hasVerifiedDestination - ? "rgba(255,255,255,0.18)" - : "rgba(255,255,255,0.22)", - transform: [{ scale: pressed ? 0.985 : 1 }], - })} + 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, + opacity: hasDestination ? (pressed ? 0.2 : 0.14) : pressed ? 0.88 : 1, + }); + }} onPress={() => { if (!isIdentityVerified) { setShowVerifyModal(true); @@ -785,8 +733,11 @@ export default function ProfilePaymentsScreen() { }} disabled={onboardingBusy} > - - + + {payoutSummary?.hasVerifiedDestination ? t("profile.payments.manageBank") : t("profile.payments.connectBank")} @@ -796,20 +747,24 @@ export default function ProfilePaymentsScreen() { {/* Stats Row - Merged into Hero Card */} - - + + - + {t("profile.payments.pending")} - + {formatAgorotCurrency( payoutSummary?.pendingAmountAgorot ?? 0, locale, @@ -817,19 +772,23 @@ export default function ProfilePaymentsScreen() { )} - + - + {t("profile.payments.paid")} - + {formatAgorotCurrency( payoutSummary?.paidAmountAgorot ?? 0, locale, @@ -840,10 +799,8 @@ export default function ProfilePaymentsScreen() { - - + + {t("profile.payments.preferenceTitle")} @@ -866,7 +823,7 @@ export default function ProfilePaymentsScreen() { ]} /> - + {effectivePreferenceMode === "scheduled_date" ? t("profile.payments.preferenceScheduledHint") : effectivePreferenceMode === "manual_hold" @@ -875,23 +832,24 @@ export default function ProfilePaymentsScreen() { {effectivePreferenceMode === "scheduled_date" ? ( - + setShowSchedulePicker((value) => !value)} - style={({ pressed }) => ({ - borderRadius: 16, - borderCurve: "continuous", - borderWidth: 1, - borderColor: palette.border as string, - backgroundColor: pressed ? palette.surface : palette.appBg, - paddingHorizontal: 16, - paddingVertical: 12, - gap: 4, - })} + 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), + backgroundColor: String(pressed ? palette.surface : palette.appBg), + }) + } > - + {t("profile.payments.preferenceScheduleAt")} {scheduledAtLabel} @@ -899,15 +857,11 @@ export default function ProfilePaymentsScreen() { {showSchedulePicker ? ( setShowSchedulePicker(false)} - style={({ pressed }) => ({ - alignSelf: "flex-start", - paddingHorizontal: 14, - paddingVertical: 10, - borderRadius: 999, - borderCurve: "continuous", - backgroundColor: pressed ? palette.surface : palette.primarySubtle, - })} + className="self-start rounded-full active:bg-surface px-md py-sm" + style={({ pressed }) => + vars({ + "--tw-bg-primary-subtle": String(palette.primarySubtle), + backgroundColor: String(pressed ? palette.surface : palette.primarySubtle), + }) + } > - + {t("common.done")} @@ -946,7 +902,7 @@ export default function ProfilePaymentsScreen() { ) : null} - + ({ - flex: 1, - alignItems: "center", - justifyContent: "center", - minHeight: 44, - borderRadius: 16, - 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 rounded-button-subtle" + style={({ pressed }) => + vars({ + "--tw-border": String(palette.border), + "--tw-bg-app-bg": String(palette.appBg), + minHeight: BrandSpacing.xxl + BrandSpacing.md, + backgroundColor: String(pressed ? palette.surface : palette.appBg), + }) + } > {t("common.cancel")} @@ -978,19 +932,19 @@ export default function ProfilePaymentsScreen() { void savePayoutPreference("scheduled_date", scheduleDraft.getTime()); }} disabled={preferenceBusy} - style={({ pressed }) => ({ - flex: 1, - alignItems: "center", - justifyContent: "center", - minHeight: 44, - borderRadius: 16, - 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] rounded-button-subtle" + style={() => + vars({ + "--tw-bg-payments-accent": String(palette.payments.accent), + minHeight: BrandSpacing.xxl + BrandSpacing.md, + opacity: preferenceBusy ? 0.6 : 1, + }) + } > - + {preferenceBusy ? t("profile.payments.preferenceSaving") : t("profile.payments.preferenceSaveSchedule")} @@ -1001,106 +955,79 @@ export default function ProfilePaymentsScreen() { ) : null} {preferenceError ? ( - + {preferenceError} ) : preferenceInfo ? ( - + {preferenceInfo} ) : null} {selectedPaymentId ? ( - - + + {t("profile.payments.receipt")} setSelectedPaymentId(null)} - style={({ pressed }) => ({ - backgroundColor: palette.surfaceAlt, - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: 999, - opacity: pressed ? 0.84 : 1, - })} + 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")} ) : !selectedPaymentDetail ? ( - + {t("profile.payments.paymentNotFound")} ) : ( - + - + {formatAgorotCurrency( role === "studio" ? selectedPaymentDetail.payment.studioChargeAmountAgorot @@ -1109,31 +1036,30 @@ export default function ProfilePaymentsScreen() { selectedPaymentDetail.payment.currency, )} - + {formatDateTime(selectedPaymentDetail.payment.createdAt, locale)} - - - + + + {t("profile.payments.status")} {getPaymentStatusLabel(selectedPaymentDetail.payment.status)} - - + + {t("profile.payments.payout")} @@ -1151,15 +1077,12 @@ export default function ProfilePaymentsScreen() { selectedPaymentDetail.invoice!.externalInvoiceUrl!, ); }} - style={({ pressed }) => ({ - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - paddingVertical: 8, - opacity: pressed ? 0.84 : 1, - })} + className="flex-row items-center justify-between py-sm active:opacity-[0.84]" > - + {t("profile.payments.downloadInvoice")} @@ -1171,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 46fba72..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,21 +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, StyleSheet, Text, View } from "react-native"; - +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, BrandType } from "@/constants/brand"; +import { BrandSpacing } from "@/constants/brand"; import { useUser } from "@/contexts/user-context"; import { api } from "@/convex/_generated/api"; import { useBrand } from "@/hooks/use-brand"; @@ -52,6 +52,7 @@ type StudioSettings = { type GoogleCalendarStatus = { connected: boolean; + hasRefreshToken: boolean; accountEmail?: string | undefined; lastError?: string | undefined; }; @@ -148,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; @@ -319,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({}); @@ -422,19 +433,20 @@ export default function StudioCalendarSettingsScreen() { }; 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 /> @@ -529,7 +558,7 @@ export default function StudioCalendarSettingsScreen() { ) : null} - + router.back()} @@ -540,36 +569,3 @@ export default function StudioCalendarSettingsScreen() { ); } - -const styles = StyleSheet.create({ - screen: { - flex: 1, - }, - content: { - paddingHorizontal: BrandSpacing.lg, - paddingBottom: 112, - 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: 10, - }, - 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 7a80502..eceb768 100644 --- a/src/app/(app)/(studio-tabs)/studio/profile/index.tsx +++ b/src/app/(app)/(studio-tabs)/studio/profile/index.tsx @@ -6,27 +6,24 @@ 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 { 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"; +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 { 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"; @@ -101,8 +98,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) { @@ -154,7 +152,6 @@ export default function StudioProfileScreen() { } const previousValue = autoExpireMinutesBefore; setAutoExpireMinutesBefore(minutes); - setIsSavingAutoExpireMinutes(true); void updateMyStudioSettings({ studioName: studioSettings.studioName ?? "", address: studioSettings.address ?? "", @@ -167,13 +164,9 @@ export default function StudioProfileScreen() { latitude: studioSettings.latitude, longitude: studioSettings.longitude, }), - }) - .catch(() => { - setAutoExpireMinutesBefore(previousValue); - }) - .finally(() => { - setIsSavingAutoExpireMinutes(false); - }); + }).catch(() => { + setAutoExpireMinutesBefore(previousValue); + }); }, [autoExpireMinutesBefore, studioSettings, updateMyStudioSettings], ); @@ -275,29 +268,37 @@ export default function StudioProfileScreen() { (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, handleRequestEdit, + onProfileHeaderLayout, palette, profileName, profileStatus, @@ -311,7 +312,9 @@ export default function StudioProfileScreen() { const profileSheetConfig = useMemo( () => ({ - content: profileSheetContent, + render: () => ({ + children: profileSheetContent, + }), steps: [profileSheetStep], initialStep: 0, padding: { @@ -326,7 +329,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 || @@ -526,16 +533,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 77fb6bf..d7e4356 100644 --- a/src/app/(auth)/sign-in-screen.tsx +++ b/src/app/(auth)/sign-in-screen.tsx @@ -14,13 +14,12 @@ 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"; const OTP_LENGTH = 6; -const GOOGLE_RED = "#EA4335"; function getErrorMessage(error: unknown, fallback: string) { if (error instanceof Error && error.message) return error.message; @@ -46,17 +45,10 @@ function MessageBanner({ const textColor = tone === "danger" ? (palette.danger as string) : (palette.textMuted as string); return ( - + - - + + - - + + { @@ -295,7 +288,7 @@ export default function SignInScreen() { size="lg" /> - + { @@ -310,29 +303,31 @@ export default function SignInScreen() { - + {t("auth.or")} - + } + icon={} onPress={() => { void handleOAuth("google"); }} @@ -349,12 +344,12 @@ export default function SignInScreen() { ) : ( - + {normalizedEmail} @@ -371,8 +366,8 @@ export default function SignInScreen() { placeholder="123456" style={styles.codeInput} /> - - + + { @@ -384,7 +379,7 @@ export default function SignInScreen() { size="lg" /> - + { @@ -405,7 +400,7 @@ export default function SignInScreen() { )} - + {infoMessage ? ( ) : null} @@ -420,11 +415,6 @@ export default function SignInScreen() { } const styles = StyleSheet.create({ - screen: { - flex: 1, - justifyContent: "space-between", - gap: BrandSpacing.xl, - }, emailInput: { ...BrandType.bodyMedium, includeFontPadding: false, @@ -438,27 +428,4 @@ const styles = StyleSheet.create({ includeFontPadding: false, fontVariant: ["tabular-nums"], }, - actionRow: { - flexDirection: "row", - gap: 10, - }, - rowAction: { - flex: 1, - }, - dividerRow: { - flexDirection: "row", - alignItems: "center", - gap: BrandSpacing.sm, - paddingVertical: 6, - }, - dividerLine: { - flex: 1, - height: 1, - }, - providerRow: { - flexDirection: "row", - alignItems: "stretch", - justifyContent: "center", - gap: 14, - }, }); diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 8cdabd4..bccd97e 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"; @@ -18,13 +19,13 @@ 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"; - 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"; @@ -130,7 +131,10 @@ function RootLayoutContent() { if (!isConvexUrlConfigured || !convex) { return ( - + {i18n.t("errors.configuration.title")} {i18n.t("errors.configuration.body")} @@ -142,21 +146,19 @@ 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 ( - + - + ); } - -const styles = StyleSheet.create({ - root: { - flex: 1, - }, - stackContainer: { - flex: 1, - }, - errorContainer: { - flex: 1, - alignItems: "center", - justifyContent: "center", - gap: 12, - paddingHorizontal: 24, - }, -}); diff --git a/src/app/modal.tsx b/src/app/modal.tsx index 443e26c..5564c04 100644 --- a/src/app/modal.tsx +++ b/src/app/modal.tsx @@ -1,32 +1,23 @@ import { Link } from "expo-router"; import { useTranslation } from "react-i18next"; -import { StyleSheet } from "react-native"; 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: 20, - }, - link: { - marginTop: 15, - paddingVertical: 15, - }, -}); diff --git a/src/app/onboarding.tsx b/src/app/onboarding.tsx index 8b181ac..3917a64 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"; @@ -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(() => { @@ -1412,9 +1405,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 +1415,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 +1437,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 +1461,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/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 c88f83e..f09fc7a 100644 --- a/src/components/home/home-agenda-widget.tsx +++ b/src/components/home/home-agenda-widget.tsx @@ -1,13 +1,15 @@ 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"; 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 = BrandSpacing.avatarLg; + type AgendaItem = { id: string; sport: string; @@ -88,8 +90,8 @@ export function HomeAgendaWidget({ @@ -110,8 +112,7 @@ export function HomeAgendaWidget({ @@ -120,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; @@ -138,23 +138,22 @@ export function HomeAgendaWidget({ style={{ flexDirection: "row", alignItems: "center", - paddingVertical: 10, - gap: 12, - 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, }} > {isToday ? relativeTime : formatGroupDate(item.startTime, locale)} - + @@ -220,7 +215,7 @@ export function HomeAgendaWidget({ {onPressAll ? ( @@ -228,7 +223,6 @@ export function HomeAgendaWidget({ style={{ ...BrandType.caption, color: palette.primary as string, - fontSize: 13, }} onPress={onPressAll} > 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 9d9b389..56dbf28 100644 --- a/src/components/home/home-header-sheet.tsx +++ b/src/components/home/home-header-sheet.tsx @@ -6,8 +6,10 @@ import { ProfileAvatar } from "@/components/ui/profile-avatar"; import type { BrandPalette } from "@/constants/brand"; import { BrandSpacing, BrandType } from "@/constants/brand"; -const SHEET_EXPANDED_CONTENT_HEIGHT = 84; +const SHEET_EXPANDED_CONTENT_HEIGHT = BrandSpacing.avatarLg + BrandSpacing.lg; const SHEET_CONTENT_GAP = BrandSpacing.sm; +const AVATAR_SIZE = BrandSpacing.avatarLg; +const BADGE_SIZE = BrandSpacing.lg; export function getHomeHeaderExpandedHeight(safeTop: number) { return safeTop + SHEET_EXPANDED_CONTENT_HEIGHT; @@ -16,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 = { @@ -41,13 +43,9 @@ export const HomeHeaderSheet = memo(function HomeHeaderSheet({ return ( @@ -72,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} @@ -85,21 +83,21 @@ export const HomeHeaderSheet = memo(function HomeHeaderSheet({ accessibilityLabel={onPressAvatar ? t("home.actions.profileTitle") : undefined} onPress={onPressAvatar} disabled={!onPressAvatar} - style={{ borderRadius: 24 }} + className="rounded-soft" > @@ -101,21 +99,16 @@ type DotStatusPillProps = { export function DotStatusPill({ backgroundColor, color, label }: DotStatusPillProps) { return ( @@ -143,8 +136,8 @@ type MetricCellProps = { /** Label + value metric pair used in job cards. */ export function MetricCell({ align = "flex-start", icon, label, value, palette }: MetricCellProps) { return ( - - + + {icon ? : null} - + {icon ? : null} ; + 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 125efdd..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( @@ -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/instructor-home-content.tsx b/src/components/home/instructor-home-content.tsx index 26b9efe..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 { 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 9f2ec40..07afe32 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 { BrandSpacing, BrandType } 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,184 @@ 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, + hasError, +}: { + application: Application; + job: RecentJob; + palette: BrandPalette; + locale: string; + zoneLanguage: "en" | "he"; + t: TFunction; + onReview: (status: "accepted" | "rejected") => void; + isReviewing: boolean; + hasError: 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")} + + + + {/* Error feedback */} + {hasError ? ( + + {t("common.error")} + + ) : null} + + {/* 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 +239,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 +276,25 @@ 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 [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) { + setErrorId(applicationId); + } finally { + setReviewingId(null); + } + }, + [reviewApplication], + ); + return ( @@ -92,17 +315,16 @@ export function StudioHomeContent({ palette={palette} tone="primary" style={{ - padding: BrandSpacing.xl, - gap: BrandSpacing.lg, + padding: BrandSpacing.insetRoomy, + gap: BrandSpacing.stackRoomy, }} > - + {t("home.studio.title")} @@ -121,10 +343,9 @@ export function StudioHomeContent({ style={{ ...BrandType.body, color: palette.onPrimary as string, - opacity: 0.84, }} > - {jobsNeedingReview.length > 0 + {pendingApplications.length > 0 ? t("home.studio.waitingCount", { count: pendingApplicants, }) @@ -134,7 +355,7 @@ export function StudioHomeContent({ - + @@ -189,109 +410,95 @@ 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) => ( - - ({ - opacity: pressed ? 0.94 : 1, - })} + + + {/* 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} + hasError={errorId === 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, - gap: 12, + flex: layout.isWideWeb && pendingApplications.length > 0 ? 0.92 : undefined, + gap: BrandSpacing.stack, }} > {recentJobs.length === 0 ? ( - + {t("home.studio.noRecent")} @@ -305,7 +512,7 @@ export function StudioHomeContent({ ) : ( - + {visibleRecentJobs.map((job, index) => ( - + - + ("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); const [applyErrorMessage, setApplyErrorMessage] = useState(null); const refreshTimerRef = useRef | null>(null); + const archiveSheetRef = useRef(null); const deferredJobsSearchQuery = useDeferredValue(jobsSearchQuery); const { contentContainerStyle: sheetContentInsets, progressViewOffset } = useTopSheetContentInsets({ @@ -61,6 +70,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]; @@ -90,6 +103,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) { @@ -123,7 +167,7 @@ export function InstructorFeed() { ] as const satisfies readonly KitDisclosureButtonGroupOption<"all" | "24h" | "72h">[], [t], ); - const headerLayoutTransition = useMemo( + const jobsHeaderLayoutTransition = useMemo( () => LinearTransition.duration(220).reduceMotion(ReduceMotion.System), [], ); @@ -131,9 +175,9 @@ export function InstructorFeed() { const jobsSheetConfig = useMemo( () => ({ stickyHeader: ( - + - + } size="sm" - railColor="rgba(52, 32, 96, 0.82)" - selectedColor="rgba(255, 255, 255, 0.2)" - labelColor="rgba(255, 255, 255, 0.76)" - selectedLabelColor={String(palette.onPrimary)} - dividerColor="rgba(255, 255, 255, 0.12)" + railColor={String(palette.surface)} + selectedColor={String(palette.primarySubtle)} + labelColor={String(palette.text)} + selectedLabelColor={String(palette.primaryPressed)} + dividerColor={String(palette.border)} /> @@ -184,13 +232,9 @@ export function InstructorFeed() { tone="error" message={applyErrorMessage} onDismiss={() => setApplyErrorMessage(null)} - borderColor="transparent" - backgroundColor={palette.dangerSubtle} - textColor={palette.danger} - iconColor={palette.danger} /> ) : null} - + ), padding: { vertical: BrandSpacing.sm, @@ -207,8 +251,8 @@ export function InstructorFeed() { applyErrorMessage, jobsFilterOptions, jobsWindowFilter, + jobsHeaderLayoutTransition, jobsSearchQuery, - headerLayoutTransition, palette, showJobsFilters, t, @@ -266,97 +310,134 @@ 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")} + {t("jobsTab.noJobsFound")} - {emptyJobsCopy} - - - {t("jobsTab.instructorFeed.emptyRefreshHint")} + {t("jobsTab.tryDifferentSearchOrTimeFilter")} - - ) : filteredAvailableJobs.length === 0 ? ( - - - - - {t("jobsTab.noJobsFound")} - - - {t("jobsTab.tryDifferentSearchOrTimeFilter")} - - - - ) : ( - - )} - - + ) : ( + + )} + + + + { + 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={ + + } + /> + + { + setIsArchiveOpen(false); + }} + onOpenStateChange={setIsArchiveOpen} + rows={archiveRows} + palette={palette} + locale={locale} + zoneLanguage={zoneLanguage} + onOpenStudio={onOpenStudio} + /> + ); } diff --git a/src/components/jobs/instructor/instructor-job-card.tsx b/src/components/jobs/instructor/instructor-job-card.tsx index 6bd0df6..e06fcd3 100644 --- a/src/components/jobs/instructor/instructor-job-card.tsx +++ b/src/components/jobs/instructor/instructor-job-card.tsx @@ -5,7 +5,7 @@ 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"; import { getZoneLabel } from "@/constants/zones"; import type { Id } from "@/convex/_generated/dataModel"; import { toSportLabel } from "@/convex/constants"; @@ -19,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">; @@ -72,17 +75,17 @@ function StudioImagePanel({ }) { return ( {imageUrl ? ( @@ -132,15 +135,8 @@ function JobExpiryPill({ return ( {showStudioImage ? ( @@ -233,7 +229,7 @@ export function InstructorJobCard({ /> ) : null} - + {metaLine} - + {formatTime(job.startTime, locale)} @@ -273,7 +269,7 @@ export function InstructorJobCard({ {formatTime(job.endTime, locale)} - + {expiry ? ( - + {job.applicationStatus ? ( ; + appliedAt: number; + jobStatus: "open" | "filled" | "cancelled" | "completed"; + closureReason?: JobClosureReason; +}; + +type InstructorJobsArchiveSheetProps = { + innerRef: React.RefObject; + onDismissed: () => void; + onOpenStateChange?: (open: boolean) => void; + rows: InstructorArchiveRow[]; + palette: BrandPalette; + locale: string; + zoneLanguage: "en" | "he"; + 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 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, +) { + 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, + }; +} + +function ArchiveStatusChip({ + label, + icon, + palette, + tone, +}: { + label: string; + icon: React.ComponentProps["name"]; + palette: BrandPalette; + tone: "primary" | "success" | "gray" | "amber" | "muted"; +}) { + const tokens = getStatusTokens(tone, palette); + + return ( + + + + {label} + + + ); +} + +function ArchiveDetailRow({ + icon, + label, + value, + palette, +}: { + icon: React.ComponentProps["name"]; + label: string; + value: string; + palette: BrandPalette; +}) { + return ( + + + + + + + {label} + + + {value} + + + + ); +} + +function ArchiveCompactRow({ + expanded, + locale, + onOpenStudio, + onToggle, + palette, + row, + t, + zoneLanguage, +}: { + expanded: boolean; + locale: string; + onOpenStudio: (studioId: Id<"studioProfiles">, jobId: Id<"jobs">) => void; + onToggle: () => void; + palette: BrandPalette; + row: InstructorArchiveRow; + t: ReturnType["t"]; + zoneLanguage: "en" | "he"; +}) { + const sportLabel = useMemo(() => toSportLabel(row.sport as never), [row.sport]); + 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 ( + + ({ + backgroundColor: pressed + ? (palette.surfaceElevated as string) + : (palette.surfaceAlt as string), + })} + > + + + + {sportLabel} + + + {`${row.studioName} · ${scheduleLabel}`} + + + + + + + + + + {payLabel} + + + + + + + {expanded ? ( + + + + + + + onOpenStudio(row.studioId, row.jobId)} + palette={palette} + tone="secondary" + /> + + + ) : null} + + ); +} + +export function InstructorJobsArchiveSheet({ + innerRef, + onDismissed, + onOpenStateChange, + rows, + palette, + locale, + zoneLanguage, + onOpenStudio, +}: InstructorJobsArchiveSheetProps) { + const { t } = useTranslation(); + const collapsedSheetHeight = useCollapsedSheetHeight(); + const [expandedApplicationId, setExpandedApplicationId] = useState(null); + const snapPoints = ["88%"]; + + const renderBackdrop = useCallback( + (props: any) => ( + + ), + [palette.appBg], + ); + + const toggleExpanded = useCallback((applicationId: string) => { + setExpandedApplicationId((current) => (current === applicationId ? null : applicationId)); + }, []); + + return ( + { + onOpenStateChange?.(index >= 0); + }} + onClose={() => { + onOpenStateChange?.(false); + setExpandedApplicationId(null); + onDismissed(); + }} + backdropComponent={renderBackdrop} + handleIndicatorStyle={{ backgroundColor: palette.borderStrong as string }} + backgroundStyle={{ + backgroundColor: palette.surfaceElevated as string, + ...getSurfaceElevationStyle(palette, "sheet"), + }} + > + + + + {t("jobsTab.archiveTitle")} + + {t("jobsTab.instructorFeed.archiveSubtitle")} + + + innerRef.current?.close()} + size={BrandSpacing.controlSm} + tone="secondary" + backgroundColorOverride={String(palette.primarySubtle)} + icon={} + /> + + + {rows.length === 0 ? ( + + + {t("jobsTab.instructorFeed.archiveEmpty")} + + + ) : ( + rows.map((row) => ( + toggleExpanded(String(row.applicationId))} + palette={palette} + row={row} + t={t} + zoneLanguage={zoneLanguage} + /> + )) + )} + + + ); +} diff --git a/src/components/jobs/jobs-tab/jobs-section-header.tsx b/src/components/jobs/jobs-tab/jobs-section-header.tsx index 832bade..f69ea33 100644 --- a/src/components/jobs/jobs-tab/jobs-section-header.tsx +++ b/src/components/jobs/jobs-tab/jobs-section-header.tsx @@ -1,5 +1,6 @@ import { View } from "react-native"; import { ThemedText } from "@/components/themed-text"; +import { BrandSpacing } from "@/constants/brand"; import { useBrand } from "@/hooks/use-brand"; type JobsSectionHeaderProps = { @@ -11,7 +12,7 @@ export function JobsSectionHeader({ title, subtitle }: JobsSectionHeaderProps) { const palette = useBrand(); return ( - + {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 ( - + ( - + ), - [], + [palette.appBg], ); const handleDateChange = (_event: any, selectedDate?: Date) => { @@ -161,27 +167,19 @@ 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 }} > - - {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 +262,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 6f24236..529c80d 100644 --- a/src/components/jobs/studio/studio-jobs-list-parts.tsx +++ b/src/components/jobs/studio/studio-jobs-list-parts.tsx @@ -24,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.soft; + const PAYMENT_STATUS_KEY: Record = { created: "jobsTab.checkout.paymentStatus.created", pending: "jobsTab.checkout.paymentStatus.pending", @@ -109,14 +112,9 @@ function MetaPill({ return ( @@ -145,8 +143,8 @@ function InlineMeta({ strong?: boolean; }) { return ( - - + + - + + onReview(application.applicationId, "rejected")} @@ -403,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..0a86255 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.surface)} + selectedColor={String(palette.primarySubtle)} + 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/layout/global-top-sheet.tsx b/src/components/layout/global-top-sheet.tsx index c1e5e83..982c990 100644 --- a/src/components/layout/global-top-sheet.tsx +++ b/src/components/layout/global-top-sheet.tsx @@ -1,20 +1,17 @@ import { usePathname } from "expo-router"; -import { isValidElement, useCallback, useEffect, useRef, useState } from "react"; +import { isValidElement, useCallback, useEffect, useRef } from "react"; import { - type LayoutChangeEvent, Platform, + type StyleProp, StyleSheet, useWindowDimensions, View, + type ViewStyle, } from "react-native"; import Reanimated, { - FadeInDown, - FadeOutUp, + FadeIn, + FadeOut, ReduceMotion, - SlideInLeft, - SlideInRight, - SlideOutLeft, - SlideOutRight, useReducedMotion, } from "react-native-reanimated"; @@ -29,13 +26,8 @@ import { } from "@/components/layout/top-sheet-registry"; 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 { getFallbackSheetColors, resolveTopSheetRouteTab } from "./global-top-sheet.helpers"; +import { getTopSheetStepHeights } from "./top-sheet.helpers"; /** * One global TopSheet mounted in RoleTabsLayout above NativeTabs. @@ -49,7 +41,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 +56,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 +150,37 @@ 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,28 +191,52 @@ export function GlobalTopSheet() { return ( - - + {renderTransitionedNode( + "children", + richChildren, + richChildren ? { flex: 1 } : undefined, + )} + + {activeConfig.overlay ? ( + - {renderTransitionedNode("children", richChildren ?? , { - flex: 1, - })} - - - {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} ); } @@ -265,18 +247,22 @@ 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} ); } @@ -289,6 +275,9 @@ const styles = StyleSheet.create({ right: 0, zIndex: 40, }, + contentClip: { + overflow: "hidden", + }, overlayLayer: { ...StyleSheet.absoluteFillObject, zIndex: 120, 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-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 8aa73fd..bfd64b8 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, @@ -16,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"; @@ -81,7 +80,6 @@ function DragHandle({ borderColor }: { borderColor: ColorValue }) { height: HANDLE_PILL_HEIGHT, borderRadius: BrandRadius.pill, backgroundColor: borderColor, - opacity: 0.5, }} /> @@ -143,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 @@ -266,12 +260,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/loading-screen.tsx b/src/components/loading-screen.tsx index 76d4fd1..5686d3b 100644 --- a/src/components/loading-screen.tsx +++ b/src/components/loading-screen.tsx @@ -1,6 +1,16 @@ +import { useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { ActivityIndicator, ScrollView, View } from "react-native"; -import Animated, { FadeIn, FadeInDown, FadeInUp } from "react-native-reanimated"; +import { View } from "react-native"; +import Animated, { + cancelAnimation, + Easing, + FadeIn, + useAnimatedStyle, + useSharedValue, + withRepeat, + withSequence, + withTiming, +} from "react-native-reanimated"; import { ThemedText } from "@/components/themed-text"; import { AppSymbol } from "@/components/ui/app-symbol"; @@ -24,103 +34,60 @@ export function LoadingScreen({ const resolvedLabel = label ?? t("common.loading"); const resolvedTitle = title ?? t("launch.title"); + // Gentle breathing pulse for the symbol + const pulse = useSharedValue(1); + useEffect(() => { + 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, + ); + return () => { + cancelAnimation(pulse); + }; + }, [pulse]); + + const symbolStyle = useAnimatedStyle(() => ({ transform: [{ scale: pulse.value }] })); + if (variant === "launch") { return ( - - - + {showBrandMark ? ( + - - {showBrandMark ? ( - - - - ) : null} - - + ) : null} + + {resolvedTitle ? ( {resolvedTitle} - - {resolvedLabel} - - - - - + ) : null} + + {resolvedLabel} + - + ); } @@ -128,21 +95,25 @@ export function LoadingScreen({ return ( - - - {resolvedLabel} - + + {showBrandMark ? ( + + + + ) : null} + + {resolvedLabel} + + ); } diff --git a/src/components/map-tab/map-tab/index.tsx b/src/components/map-tab/map-tab/index.tsx index fa9360a..7ea60e0 100644 --- a/src/components/map-tab/map-tab/index.tsx +++ b/src/components/map-tab/map-tab/index.tsx @@ -1,3 +1,4 @@ +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"; @@ -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,12 +25,11 @@ export default function MapTabScreen() { mapCameraPadding, mapPalette, mapPin, + studios, noopMapPress, - overlayBottom, palette, pendingChangeCount, persistedZoneIds, - remoteZones, saveError, selectedZoneIds, selectedZones, @@ -40,6 +41,9 @@ export default function MapTabScreen() { zoneSearch, zoneModeActive, } = useMapTabController(); + const handlePressStudio = (studioId: string) => { + router.push(`/instructor/jobs/studios/${encodeURIComponent(studioId)}` as Href); + }; const router = useRouter(); @@ -67,7 +71,7 @@ export default function MapTabScreen() { return ; } - if (!isMapBodyReady || remoteZones === undefined) { + if (!isMapBodyReady) { return ( ); 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 a60aaef..6880f19 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, StudioMarker } 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,11 +14,11 @@ type MapMobileStageProps = { mapBackgroundColor: string; isFocused: boolean; mapPin: QueueMapPin | null; + studios: StudioMapMarker[]; selectedZoneIds: string[]; focusZoneId: string | null; zoneModeActive: boolean; isSaving: boolean; - overlayBottom: number; cameraPadding: { top: number; right: number; @@ -29,6 +29,7 @@ type MapMobileStageProps = { onPressStudio: (studioId: string) => void; onPressZone: (zoneId: string) => void; onPressMap: () => void; + onPressStudio: (studioId: string) => void; onEditToggle: () => void; }; @@ -38,16 +39,17 @@ export function MapMobileStage({ mapBackgroundColor, isFocused, mapPin, + studios, selectedZoneIds, focusZoneId, zoneModeActive, isSaving, - overlayBottom, cameraPadding, studios, onPressStudio, onPressZone, onPressMap, + onPressStudio, onEditToggle, }: MapMobileStageProps) { if (!isFocused) { @@ -59,6 +61,7 @@ export function MapMobileStage({ - + 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 ff81685..a44ae48 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 } 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: _mapPalette, selectedZones, onPressZone, t, @@ -30,7 +32,7 @@ export function MapSheetHeader({ zoneSearch, }: MapSheetHeaderProps) { return ( - + 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 8712aa2..9589d27 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,9 +3,17 @@ 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, BrandType } from "@/constants/brand"; +import { type BrandPalette, BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; import type { ZoneOption } from "@/constants/zones"; +// Map web command panel - desktop-focused with fixed width panel +const PANEL_WIDTH = BrandSpacing.shellCommandPanel; +const PANEL_RADIUS = BrandRadius.soft; +const INNER_RADIUS = BrandRadius.medium; +const METRIC_RADIUS = BrandRadius.hard; +const TERRITORY_RADIUS = BrandRadius.hard; +const ZONE_SELECT_RADIUS = BrandRadius.hard; + type MapWebCommandPanelProps = { t: TFunction; palette: BrandPalette; @@ -40,20 +48,19 @@ export function MapWebCommandPanel({ return ( - + @@ -71,24 +78,24 @@ export function MapWebCommandPanel({ - + ) : null} - + {selectedZones.length === 0 ? ( - + @@ -277,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 @@ -297,18 +305,18 @@ export function MapWebCommandPanel({ style={({ pressed }) => ({ alignItems: "center", justifyContent: "center", - paddingHorizontal: 14, - paddingVertical: 14, + 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); @@ -345,13 +353,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.controlX, + paddingVertical: BrandSpacing.md, opacity: pressed ? 0.92 : 1, })} > @@ -360,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 3fb4cad..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 @@ -2,7 +2,11 @@ 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, BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; + +// Map web header panels - shares radii with command panel +const PANEL_RADIUS = BrandRadius.soft; +const INNER_RADIUS = BrandRadius.medium; type MapWebHeaderPanelsProps = { t: TFunction; @@ -28,16 +32,16 @@ export function MapWebHeaderPanels({ onReset, }: MapWebHeaderPanelsProps) { return ( - + @@ -73,13 +74,13 @@ export function MapWebHeaderPanels({ @@ -115,23 +113,22 @@ export function MapWebHeaderPanels({ > {hasChanges ? t("mapTab.web.statePending") : t("mapTab.web.stateReady")} - + @@ -151,19 +148,18 @@ export function MapWebHeaderPanels({ {focusedZoneLabel @@ -183,7 +179,7 @@ export function MapWebHeaderPanels({ - + + - + [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(); @@ -46,8 +58,8 @@ export function useMapTabController() { api.instructorZones.getMyInstructorZones, currentUser?.role === "instructor" ? {} : "skip", ); - const studiosWithLocations = useQuery( - api.users.getStudiosWithLocations, + const remoteStudios = useQuery( + api.users.getInstructorMapStudios, currentUser?.role === "instructor" ? {} : "skip", ); const saveZones = useMutation(api.instructorZones.setMyInstructorZones); @@ -139,22 +151,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 = @@ -163,7 +161,7 @@ export function useMapTabController() { () => shouldBuildZoneCityItems ? buildZoneCityListItems({ - groups: zoneCityGroups, + groups: STATIC_ZONE_CITY_GROUPS, language: zoneLanguage, query: zoneSearch, expandedCityKeys: expandedCityKeySet, @@ -174,17 +172,26 @@ 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 visibleStudioMarkers = useMemo( + () => + (remoteStudios ?? []).filter((studio) => + selectedZoneIds.length > 0 ? selectedZoneIds.includes(studio.zone) : true, + ), + [remoteStudios, 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; @@ -200,20 +207,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) => @@ -225,7 +232,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(); @@ -244,7 +251,7 @@ export function useMapTabController() { ); } }, - [applySelectedZoneIds, selectedZoneIds, zoneCityGroupByKey], + [applySelectedZoneIds, selectedZoneIds], ); const openZoneEditor = useCallback(() => { @@ -351,6 +358,7 @@ export function useMapTabController() { zoneLanguage={zoneLanguage} zoneModeActive={zoneModeActive} palette={palette} + mapPalette={mapPalette} onPressZone={handleZoneResultPress} onPressCity={handleCityResultPress} onToggleCityExpanded={toggleCityExpanded} @@ -360,6 +368,7 @@ export function useMapTabController() { isSheetExpanded, handleCityResultPress, handleZoneResultPress, + mapPalette, palette, saveError, toggleCityExpanded, @@ -378,6 +387,7 @@ export function useMapTabController() { onChangeSearch={handleMapSheetSearchChange} onFocusSearch={openSearchSheet} palette={palette} + mapPalette={mapPalette} selectedZones={selectedZones} onPressZone={setFocusZoneId} t={t} @@ -397,7 +407,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, @@ -409,6 +419,7 @@ export function useMapTabController() { handleMapSheetSearchChange, handleSheetStepChange, mapExpandedResults, + mapPalette, openSearchSheet, palette, selectedZones, @@ -438,6 +449,7 @@ export function useMapTabController() { mapCameraPadding, mapPalette, mapPin, + studios: visibleStudioMarkers, noopMapPress, overlayBottom, palette, 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 d7327a3..b2cae3a 100644 --- a/src/components/map-tab/map/map-selected-zones-strip.tsx +++ b/src/components/map-tab/map/map-selected-zones-strip.tsx @@ -1,14 +1,24 @@ 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; +const COMPACT_ZONE_PILL_RADIUS = BrandRadius.cardSubtle - BrandSpacing.sm; + type MapSelectedZonesStripProps = { selectedZones: ZoneOption[]; focusZoneId: string | null; zoneLanguage: "en" | "he"; palette: BrandPalette; + mapPalette: ReturnType; onPressZone: (zoneId: string) => void; }; @@ -17,6 +27,7 @@ export function MapSelectedZonesStrip({ focusZoneId, zoneLanguage, palette, + mapPalette: _mapPalette, onPressZone, }: MapSelectedZonesStripProps) { const { t } = useTranslation(); @@ -44,10 +55,14 @@ export function MapSelectedZonesStrip({ compact fullWidth={false} onPress={() => onPressZone(zone.id)} + backgroundColor={palette.surfaceAlt} + selectedBackgroundColor={palette.primarySubtle} + labelColor={palette.text} + selectedLabelColor={palette.primary} style={{ - minHeight: 34, + minHeight: COMPACT_ZONE_PILL_MIN_HEIGHT, paddingHorizontal: BrandSpacing.md, - paddingVertical: 4, + paddingVertical: BrandSpacing.xs, }} /> ); @@ -55,11 +70,11 @@ export function MapSelectedZonesStrip({ ) : ( ; onPressZone: (zoneId: string) => void; onPressCity: (cityKey: string) => void; onToggleCityExpanded: (cityKey: string) => void; @@ -28,6 +35,7 @@ export function MapSheetResults({ zoneLanguage, zoneModeActive, palette, + mapPalette: _mapPalette, onPressZone, onPressCity, onToggleCityExpanded, @@ -44,7 +52,7 @@ export function MapSheetResults({ {saveError ? ( } ListEmptyComponent={ @@ -210,7 +218,6 @@ export function MapSheetResults({ : zoneModeActive && isFullySelected ? (palette.primary as string) : (palette.textMuted as string), - opacity: 0.92, }} > {summary} @@ -236,9 +243,9 @@ export function MapSheetResults({ hitSlop={8} onPress={() => onToggleCityExpanded(item.group.cityKey)} style={({ pressed }) => ({ - paddingHorizontal: BrandSpacing.md, + paddingHorizontal: BrandSpacing.controlX, paddingVertical: BrandSpacing.sm, - opacity: pressed ? 0.82 : 1, + backgroundColor: pressed ? (palette.surface as string) : undefined, })} > diff --git a/src/components/maps/queue-map.native.helpers.ts b/src/components/maps/queue-map.native.helpers.ts index 90bb485..684b69b 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, StudioMarker } from "./queue-map.types"; +import type { QueueMapPin, StudioMapMarker } from "./queue-map.types"; export type Expression = unknown; export type AnyStyleLayer = Record; @@ -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; @@ -51,6 +52,90 @@ 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") + ); +} + +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") + ); +} + +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") + ); +} + +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, @@ -58,12 +143,15 @@ 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) => { const nextLayer = { ...layer }; 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") { @@ -90,23 +178,72 @@ export function withMapPersonality( ) { paint["fill-color"] = palette.landcover; } - if (sourceLayer.includes("road") && layerType === "line") { - paint["line-color"] = palette.roadLine; + if (isRoadLayer(id, sourceLayer) && layerType === "line") { + 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"] = 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") { paint["fill-color"] = palette.buildingFill; + 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"] = 1; + paint["text-halo-width"] = 0.55; + paint["text-opacity"] = 1; } nextLayer.paint = paint; + nextLayer.layout = layout; 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 { @@ -216,39 +353,45 @@ export function createPinShape(pin: QueueMapPin | null): GeoJSON.FeatureCollecti }; } -/** Returns unique studio image URLs and their assigned keys, for registration via . */ -export function getStudioImageEntries( - studios: StudioMarker[], -): Array<{ imageKey: string; imageUrl: string }> { - const seen = new Set(); - const entries: Array<{ imageKey: string; imageUrl: string }> = []; - for (const studio of studios) { - if (!studio.profileImageUrl) continue; - if (seen.has(studio.profileImageUrl)) continue; - seen.add(studio.profileImageUrl); - const imageKey = `studio-img-${entries.length}`; - entries.push({ imageKey, imageUrl: studio.profileImageUrl }); - } - return entries; +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: StudioMarker[]): GeoJSON.FeatureCollection { - if (studios.length === 0) return { type: "FeatureCollection", features: [] }; +export function createStudioMarkersGeoJson( + studios: readonly StudioMapMarker[], + variant: "logo" | "fallback", +): GeoJSON.FeatureCollection { return { type: "FeatureCollection", - features: studios.map((studio, index) => ({ - type: "Feature" as const, - properties: { - imageKey: studio.profileImageUrl ? `studio-img-${index}` : "", - studioId: studio.studioId, - studioName: studio.studioName, - hasImage: Boolean(studio.profileImageUrl), - }, - geometry: { - type: "Point" as const, - coordinates: [studio.longitude, studio.latitude], - }, - })), + 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], + }, + })), }; } diff --git a/src/components/maps/queue-map.native.tsx b/src/components/maps/queue-map.native.tsx index fc44484..1a1af00 100644 --- a/src/components/maps/queue-map.native.tsx +++ b/src/components/maps/queue-map.native.tsx @@ -1,9 +1,11 @@ import { Camera, GeoJSONSource, + type GeoJSONSourceRef, Images, Layer, Map as MapLibreMap, + type MapRef, } from "@maplibre/maplibre-react-native"; import Constants from "expo-constants"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -22,6 +24,7 @@ import { IconSymbol } from "../ui/icon-symbol"; import { KitSurface } from "../ui/kit"; import { type AnyStyleSpec, + createFallbackMapStyle, createPinShape, createStudioMarkersGeoJSON, createZoneFilter, @@ -36,12 +39,26 @@ 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; +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; +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; export const QueueMap = memo(function QueueMap({ mode, pin, + studios = [], selectedZoneIds, focusZoneId, isEditing = mode === "zoneSelect", @@ -51,6 +68,7 @@ export const QueueMap = memo(function QueueMap({ onPressStudio, onPressZone, onPressMap, + onPressStudio, onUseGps, showGpsButton = true, showAttributionButton = true, @@ -66,6 +84,10 @@ export const QueueMap = memo(function QueueMap({ const [retryNonce, setRetryNonce] = useState(0); 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 = @@ -83,12 +105,12 @@ 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<{ - showAttribution?: () => void; - } | null>(null); + const mapRef = useRef(null); + const studioSourceRef = useRef(null); const mapLoadStateRef = useRef("loading"); const cameraRef = useRef<{ setStop: (config: unknown) => void; @@ -99,27 +121,31 @@ export const QueueMap = memo(function QueueMap({ [selectedZoneIds, zoneIdProperty], ); const pinShape = useMemo(() => createPinShape(pin), [pin]); - const studioMarkersGeoJSON = useMemo(() => createStudioMarkersGeoJSON(studios ?? []), [studios]); - const studioImageEntries = useMemo(() => getStudioImageEntries(studios ?? []), [studios]); - const handleMapPress = useCallback( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (event: any) => { - const features = event?.nativeEvent?.features ?? []; - const firstFeature = features[0]; - // Studio marker tapped — navigate to studio profile - if (firstFeature?.properties?.studioId) { - onPressStudio?.(firstFeature.properties.studioId); - return; - } - // Pin-drop mode: record dropped pin coordinate - if (mode !== "pinDrop") return; - if (!onPressMap) return; - const native = event?.nativeEvent ?? event; - const coordinates = native?.lngLat as [number, number] | undefined; - if (!coordinates) return; - onPressMap({ latitude: coordinates[1], longitude: coordinates[0] }); - }, - [mode, onPressMap, onPressStudio], + const showStudioMarkers = studios.length > 0 && currentZoom >= STUDIO_MARKER_MIN_ZOOM; + const studioMarkerImages = useMemo( + () => ({ + [`${STUDIO_PIN_ICON_KEY_PREFIX}shell`]: { source: STUDIO_PIN_SHELL_IMAGE, sdf: true }, + }), + [], + ); + const studioMarkerSource = useMemo( + () => ({ + type: "FeatureCollection", + features: studios.map((studio) => ({ + type: "Feature", + geometry: { + type: "Point", + coordinates: [studio.longitude, studio.latitude], + }, + properties: { + studioId: studio.studioId, + studioName: studio.studioName, + iconKey: `${STUDIO_PIN_ICON_KEY_PREFIX}shell`, + ...(studio.mapMarkerColor ? { markerColor: studio.mapMarkerColor } : {}), + }, + })), + }), + [studios], ); const handleRetry = useCallback(() => { setBaseMapStyle(null); @@ -162,6 +188,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; @@ -258,9 +299,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, }, ]} > @@ -296,7 +337,18 @@ export const QueueMap = memo(function QueueMap({ onDidFailLoadingMap={() => { updateMapLoadState("error", t("mapTab.native.unavailableBody")); }} - onPress={handleMapPress as any} + 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; + const native = event?.nativeEvent ?? event; + const coordinates = native?.lngLat as [number, number] | undefined; + if (!coordinates) return; + onPressMap({ latitude: coordinates[1], longitude: coordinates[0] }); + }} > + {showStudioMarkers ? : null} + {showStudioMarkers ? ( + { + const native = event?.nativeEvent ?? event; + 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); + } + }} + > + + + + + + ) : null} + @@ -425,11 +613,11 @@ export const QueueMap = memo(function QueueMap({ style={({ pressed }) => [ styles.gps, { - width: 58, - height: 58, + width: GPS_BUTTON_SIZE, + height: GPS_BUTTON_SIZE, alignItems: "center", justifyContent: "center", - borderWidth: 1.2, + borderWidth: StyleSheet.hairlineWidth, borderRadius: BrandRadius.button, borderCurve: "continuous", backgroundColor: palette.surface as string, @@ -439,7 +627,7 @@ export const QueueMap = memo(function QueueMap({ }, ]} > - + ) : null} @@ -455,12 +643,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} @@ -479,32 +667,32 @@ 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: 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.types.ts b/src/components/maps/queue-map.types.ts index 875f0ba..e196001 100644 --- a/src/components/maps/queue-map.types.ts +++ b/src/components/maps/queue-map.types.ts @@ -5,14 +5,15 @@ export type QueueMapPin = { longitude: number; }; -/** A studio shown as a marker on the map with its logo avatar. */ -export type StudioMarker = { +export type StudioMapMarker = { studioId: string; studioName: string; + zone: string; latitude: number; longitude: number; - profileImageUrl?: string; - sport?: string; + address?: string; + logoImageUrl?: string; + mapMarkerColor?: string; }; export type QueueMapMode = "zoneSelect" | "pinDrop"; @@ -27,6 +28,7 @@ export type QueueMapViewPadding = { export type QueueMapProps = { mode: QueueMapMode; pin: QueueMapPin | null; + studios?: StudioMapMarker[]; selectedZoneIds: string[]; focusZoneId: string | null; isEditing?: boolean; @@ -36,6 +38,7 @@ export type QueueMapProps = { onPressStudio?: (studioId: string) => void; onPressZone?: (zoneId: string) => void; onPressMap?: (pin: QueueMapPin) => void; + onPressStudio?: (studioId: string) => void; onUseGps?: () => void; showGpsButton?: boolean; showAttributionButton?: boolean; diff --git a/src/components/maps/queue-map.web.tsx b/src/components/maps/queue-map.web.tsx index 620563b..fe59c88 100644 --- a/src/components/maps/queue-map.web.tsx +++ b/src/components/maps/queue-map.web.tsx @@ -1,13 +1,18 @@ 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, 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"; +// Map web - desktop-focused map display with placeholder grid pattern +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(); const palette = useBrand(); @@ -37,13 +42,13 @@ export function QueueMap(props: QueueMapProps) { style={{ flex: 1, backgroundColor: palette.surfaceAlt as string, - padding: 20, + padding: BrandSpacing.lg, }} > - + - + @@ -92,15 +94,19 @@ export function QueueMap(props: QueueMapProps) { - + @@ -118,11 +124,11 @@ export function QueueMap(props: QueueMapProps) { @@ -141,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, }} /> @@ -154,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, }} /> @@ -166,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" }], @@ -179,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" }], @@ -190,24 +196,24 @@ export function QueueMap(props: QueueMapProps) { {node.label} - + @@ -393,12 +395,12 @@ export function QueueMap(props: QueueMapProps) { {selectedPreview.length > 0 ? ( - + {selectedPreview.map((node) => ( = { 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 ( ); } @@ -80,16 +76,9 @@ export function PaymentActivityList({ }: PaymentActivityListProps) { const { t } = useTranslation(); return ( - - - + + + {title} {subtitle ? ( @@ -105,7 +94,7 @@ export function PaymentActivityList({ {items.length === 0 ? ( - + {emptyLabel} @@ -129,26 +118,18 @@ 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: 12, - paddingHorizontal: BrandSpacing.md, - backgroundColor: pressed && onSelectPaymentId ? palette.surfaceAlt : "transparent", + backgroundColor: + pressed && onSelectPaymentId ? palette.surfaceAlt : palette.surface, borderBottomWidth: index < items.length - 1 ? 1 : 0, borderBottomColor: palette.border, })} > - - + + - - {sportLabel} - + {sportLabel} {item.job ? formatDateTime(item.job.startTime, locale) @@ -158,7 +139,7 @@ export function PaymentActivityList({ - + - + {paymentStatus} diff --git a/src/components/profile/calendar-connection-row.tsx b/src/components/profile/calendar-connection-row.tsx index 79a6ce6..878fff1 100644 --- a/src/components/profile/calendar-connection-row.tsx +++ b/src/components/profile/calendar-connection-row.tsx @@ -1,8 +1,8 @@ -import { ActivityIndicator, Image, Pressable, StyleSheet, Text, View } from "react-native"; import type { ImageSourcePropType } from "react-native"; +import { ActivityIndicator, Image, Pressable, StyleSheet, Text, View } from "react-native"; import { AppSymbol } from "@/components/ui/app-symbol"; -import { BrandRadius, BrandSpacing, BrandType, type BrandPalette } from "@/constants/brand"; +import { type BrandPalette, BrandRadius, BrandSpacing, BrandType } from "@/constants/brand"; type CalendarConnectionRowProps = { iconSource: ImageSourcePropType; @@ -58,7 +58,7 @@ export function CalendarConnectionRow({ ) : ( )} @@ -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} /> ); } @@ -172,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 = ( @@ -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) => ( + @@ -195,25 +214,47 @@ export function ProfileSubpageSheetHost({ return { stickyHeader: ( - router.back()} - {...(isDiditRoute || isPaymentsRoute ? { accentColor } : {})} - /> + + router.back()} + {...(isDiditRoute || isPaymentsRoute ? { accentColor } : {})} + /> + ), padding: { - vertical: BrandSpacing.sm, - horizontal: BrandSpacing.lg, + 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, backgroundColor: accentColor, topInsetColor: accentColor, }; - }, [activeRoute, accessoryContext?.accessories, palette.primary, palette.didit.accent, palette.payments.accent, router]); + }, [ + activeRoute, + accessoryContext?.accessories, + headerMeasuredHeight, + onHeaderLayout, + palette.primary, + palette.didit.accent, + palette.payments.accent, + router, + safeBottom, + safeTop, + screenHeight, + ]); useGlobalTopSheet("profile", config, ownerId); @@ -231,8 +272,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 +295,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 +309,6 @@ export function ProfileIndexScrollView({ { paddingTop: collapsedSheetHeight + topSpacing, paddingBottom: bottomSpacing + safeBottom, - paddingHorizontal: 0, }, contentContainerStyle, ]} @@ -277,15 +317,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 ? ( - + } + size={ICON_SIZE} + 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/action-button.tsx b/src/components/ui/action-button.tsx index a2a964a..cf8fe6b 100644 --- a/src/components/ui/action-button.tsx +++ b/src/components/ui/action-button.tsx @@ -1,13 +1,17 @@ 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"; +// 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; onPress: () => void; @@ -20,7 +24,6 @@ type ActionButtonProps = { accessibilityLabel?: string; shape?: ActionButtonShape; size?: ActionButtonSize; - /** Use mesh gradient background instead of solid color (Vercel/Linear style) */ meshGradient?: boolean; }; @@ -36,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."); @@ -44,9 +47,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 useMeshGradient = meshGradient && tone === "primary" && !isDisabled; + const minHeight = size === "lg" ? BUTTON_HEIGHT_LG : BUTTON_HEIGHT_MD; + const minWidth = shape === "square" ? minHeight : BUTTON_MIN_WIDTH; const backgroundColor = isDisabled ? tone === "primary" ? (palette.primaryPressed as string) @@ -54,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) @@ -84,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 ? ( @@ -116,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 c9ab58c..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, @@ -169,7 +170,7 @@ export function AddressAutocomplete({ style={({ pressed }) => [ styles.suggestion, { - backgroundColor: pressed ? palette.primarySubtle : "transparent", + backgroundColor: pressed ? palette.primarySubtle : palette.surface, }, ]} onPress={() => { @@ -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/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/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/icon-symbol.tsx b/src/components/ui/icon-symbol.tsx index fb76731..c843c11 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", @@ -55,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/components/ui/kit/kit-button-group.tsx b/src/components/ui/kit/kit-button-group.tsx index c3f7de0..851d00d 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,38 @@ 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,34 +90,31 @@ 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.primaryPressed) : String(palette.surfaceAlt)); + const resolvedSelectedBg = + selectedBackgroundColor ?? + (tone === "onPrimary" ? String(palette.primary) : String(palette.surfaceElevated)); + const resolvedLabelColorFinal = + labelColor ?? (tone === "onPrimary" ? String(palette.onPrimary) : String(palette.textMuted)); + const resolvedSelectedLabelColorFinal = selectedLabelColor ?? String(palette.onPrimary); + const resolvedDividerColorFinal = + dividerColor ?? + (tone === "onPrimary" ? String(palette.onPrimary) : String(palette.borderStrong)); return ( ({ return ( ({ {showDivider ? ( @@ -136,16 +153,15 @@ export function KitButtonGroup({ {selected ? ( @@ -160,33 +176,42 @@ export function KitButtonGroup({ triggerSelectionHaptic(); onChange(option.value); }} + className="w-full" style={({ pressed }) => [ - styles.segmentPressable, { - opacity: option.disabled ? 0.45 : pressed ? 0.9 : 1, - } as ViewStyle, + borderRadius: metrics.radius, + backgroundColor: option.disabled + ? String(palette.surface) + : pressed + ? tone === "onPrimary" + ? String(palette.primaryPressed) + : String(palette.surface) + : undefined, + }, ]} > - {option.icon ? {option.icon} : null} + {option.icon ? ( + {option.icon} + ) : null} {option.label} @@ -204,51 +229,3 @@ const alignSelfMap: Record [ { - minHeight: 40, - borderRadius: BrandRadius.button - 4, - borderCurve: "continuous", - alignItems: "center", - justifyContent: "center", - paddingHorizontal: 14, - paddingVertical: 8, - 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, @@ -42,8 +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 89c2582..412f37b 100644 --- a/src/components/ui/kit/kit-disclosure-button-group.tsx +++ b/src/components/ui/kit/kit-disclosure-button-group.tsx @@ -1,14 +1,9 @@ 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, - FadeOutRight, - LinearTransition, - ReduceMotion, -} from "react-native-reanimated"; +import Animated, { LinearTransition, 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"; @@ -25,6 +20,7 @@ type KitDisclosureButtonGroupProps = { expanded: boolean; onChange: (value: T) => void; onToggleExpanded: () => void; + showTriggerWhenExpanded?: boolean; triggerLabel?: string; triggerIcon?: ReactNode; accessibilityLabel: string; @@ -39,20 +35,20 @@ type KitDisclosureButtonGroupProps = { const SIZE_PRESETS = { sm: { - railPadding: 4, - railRadius: 16, - sectionRadius: 12, - minHeight: 40, - paddingHorizontal: 14, - separatorInset: 10, + railPadding: BrandSpacing.xs, + railRadius: BrandRadius.buttonSubtle, + 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.input, + sectionRadius: BrandRadius.buttonSubtle, + minHeight: BrandSpacing.iconContainer + 6, + paddingHorizontal: BrandSpacing.lg, + separatorInset: BrandSpacing.sm + 3, }, } as const; @@ -64,6 +60,7 @@ export function KitDisclosureButtonGroup({ expanded, onChange, onToggleExpanded, + showTriggerWhenExpanded = true, triggerLabel, triggerIcon, accessibilityLabel, @@ -77,17 +74,18 @@ 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.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); + const isIconOnlyTrigger = !triggerLabel; return ( ({ ]} > {expanded ? ( - + {options.map((option, index) => { const selected = option.value === value; return ( - + {index > 0 ? ( ({ {selected ? ( ({ triggerSelectionHaptic(); onChange(option.value); }} - style={({ pressed }) => [styles.segmentButton, { opacity: pressed ? 0.9 : 1 }]} + className="relative z-10" + style={({ pressed }) => [ + { + borderRadius: metrics.sectionRadius, + backgroundColor: pressed ? String(palette.primaryPressed) : undefined, + }, + ]} > - {option.icon ? {option.icon} : null} + {option.icon ? ( + {option.icon} + ) : null} {option.label} @@ -177,94 +177,62 @@ export function KitDisclosureButtonGroup({ ) : null} - { - triggerSelectionHaptic(); - onToggleExpanded(); - }} - style={({ pressed }) => [ - styles.segmentWrap, - styles.triggerPressable, - { opacity: pressed ? 0.92 : 1 }, - ]} + - { + triggerSelectionHaptic(); + onToggleExpanded(); + }} + className="justify-center" + style={({ pressed }) => [ { - minHeight: metrics.minHeight, - paddingHorizontal: triggerLabel ? metrics.paddingHorizontal : 12, borderRadius: metrics.sectionRadius, - } satisfies ViewStyle, + backgroundColor: pressed ? String(palette.primaryPressed) : undefined, + }, ]} > - {triggerIcon ? {triggerIcon} : null} - {triggerLabel ? ( - - {triggerLabel} - - ) : null} - - + + {triggerIcon ? ( + {triggerIcon} + ) : null} + {triggerLabel ? ( + + {triggerLabel} + + ) : null} + + + ); } - -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: 6, - }, - triggerPressable: { - justifyContent: "center", - }, - triggerButton: { - alignItems: "center", - justifyContent: "center", - flexDirection: "row", - gap: 6, - }, - iconWrap: { - alignItems: "center", - justifyContent: "center", - }, - segmentLabel: { - ...BrandType.bodyMedium, - fontSize: 15, - fontWeight: "700", - includeFontPadding: false, - textAlign: "center", - textAlignVertical: "center", - }, -}); diff --git a/src/components/ui/kit/kit-floating-badge.tsx b/src/components/ui/kit/kit-floating-badge.tsx index a8dbfe9..112d149 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,10 @@ 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 +51,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 ( - - {/* Tiny dot pattern for subtle grain texture */} - - - - - - - - ); -} - /** * 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, @@ -55,51 +31,43 @@ export function MeshGradientView({ children, ...props }: MeshGradientViewProps) { + 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; + 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} )} @@ -108,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 a26cfaf..8c8d55b 100644 --- a/src/components/ui/kit/kit-segmented-toggle.tsx +++ b/src/components/ui/kit/kit-segmented-toggle.tsx @@ -1,6 +1,6 @@ import { Pressable, type StyleProp, Text, View, type ViewStyle } 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"; @@ -24,18 +24,18 @@ export function KitSegmentedToggle({ style, }: KitSegmentedToggleProps) { const palette = useBrand(); + const disabledBackgroundColor = palette.surface as string; + const pressedBackgroundColor = palette.surfaceElevated as string; 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) - : (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" ? ( @@ -70,8 +74,8 @@ export function KitSocialIconButton({ onPress(); }} style={({ pressed }) => ({ - borderRadius: 999, - opacity: pressed ? 0.84 : 1, + borderRadius: BrandRadius.pill, + backgroundColor: pressed ? pressedBackgroundColor : idleBackgroundColor, })} > {circle} diff --git a/src/components/ui/kit/kit-status-badge.tsx b/src/components/ui/kit/kit-status-badge.tsx index 7d4ac71..47eca78 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,14 @@ 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..a5156e4 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,31 @@ 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 = { @@ -46,22 +67,20 @@ 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 }, ], })); return ( { @@ -85,34 +103,29 @@ 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-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-switch.tsx b/src/components/ui/kit/kit-switch.tsx index b325346..1809241 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.switchTrackOn 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, + }, ]} > + {label ? ( ) : null} {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 5becc40..ff53fee 100644 --- a/src/components/ui/native-search-field.tsx +++ b/src/components/ui/native-search-field.tsx @@ -7,16 +7,35 @@ 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"; +const SEARCH_SIZE_SM = { + containerMinHeight: BrandSpacing.controlSm + BrandSpacing.sm, + inputMinHeight: BrandSpacing.controlSm + BrandSpacing.xxs + BrandSpacing.xxs, + horizontalPadding: BrandSpacing.md, + iconSize: BrandSpacing.iconSm - BrandSpacing.xxs, + clearIconSize: BrandSpacing.iconSm - BrandSpacing.xxs, + radius: BrandRadius.buttonSubtle, +} as const; + +const SEARCH_SIZE_MD = { + containerMinHeight: BrandSpacing.controlSm + BrandSpacing.md, + inputMinHeight: BrandSpacing.controlSm + BrandSpacing.sm, + horizontalPadding: BrandSpacing.lg, + iconSize: BrandSpacing.iconSm + BrandSpacing.xxs, + clearIconSize: BrandSpacing.iconSm - BrandSpacing.xxs, + radius: BrandRadius.input, +} as const; + type NativeSearchFieldProps = Omit & { value: string; onChangeText: (value: string) => void; clearAccessibilityLabel?: string; size?: "md" | "sm"; containerStyle?: StyleProp; + animateLayout?: boolean; }; export function NativeSearchField({ @@ -26,6 +45,7 @@ export function NativeSearchField({ clearAccessibilityLabel = "Clear search", size = "md", containerStyle, + animateLayout = false, style, ...rest }: NativeSearchFieldProps) { @@ -35,30 +55,22 @@ 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; + 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 a956b5e..6363261 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 = { @@ -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,23 +42,27 @@ 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 ( - + {progressCount && progressIndex ? ( {Array.from({ length: progressCount }, (_, index) => { @@ -68,12 +71,11 @@ export function SheetHeaderBlock({ return ( ); @@ -88,22 +90,23 @@ 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, + }, + ]} > {trailingIcon} @@ -115,12 +118,12 @@ export function SheetHeaderBlock({ ) : null} - + {title} {subtitle ? ( - + {subtitle} ) : null} 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; +} diff --git a/src/constants/brand.ts b/src/constants/brand.ts index 55f9760..d198a52 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", @@ -155,44 +161,52 @@ const ExplicitBrandPalette: Record = { const NativeMapBrandPalette = { light: { - styleBackground: "#E8F0E7", - waterFill: "#B9D7F2", - waterLine: "#88B9E8", - landcover: "#D5E8C8", - roadLine: "#FFFFFF", - buildingFill: "#E3DDD4", - zoneOutline: "#8F9987", + styleBackground: "#F4F6F8", + waterFill: "#B2D3ED", + waterLine: "#84B2D9", + landcover: "#E1E8DE", + roadLine: "#EEF1F4", + roadPrimary: "#E1E6EC", + roadSecondary: "#CCD3DB", + roadTertiary: "#B8C2CD", + buildingFill: "#D8DDE3", + zoneOutline: "#8E9C84", zoneOutlineOpacity: 0.28, - previewFill: "#A8C8A0", + previewFill: "#CFE5BC", previewFillOpacity: 0.14, - previewOutline: "#7A8D74", + previewOutline: "#95B85F", previewOutlineOpacity: 0.42, - selectedOutline: "#7C3AED", + selectedOutline: "#8FBF3C", selectedOutlineOpacity: 1.0, - surfaceAlt: "#F7FBF4", - primary: "#7C3AED", // Vibrant purple - text: "#182018", - textHalo: "#F7FBF4", + surfaceAlt: "#F8FAFB", + primary: "#8FBF3C", + markerAccent: "#2AA8E8", + text: "#252A31", + textHalo: "#F8FAFB", }, dark: { - styleBackground: "#0E1412", - waterFill: "#14344D", - waterLine: "#22567D", - landcover: "#18241B", - roadLine: "#313942", - buildingFill: "#22272B", - zoneOutline: "#4C5A50", + styleBackground: "#14181D", + waterFill: "#1A3447", + waterLine: "#365B76", + landcover: "#1A2024", + roadLine: "#2A3138", + roadPrimary: "#4B5563", + roadSecondary: "#343B44", + roadTertiary: "#2B3138", + buildingFill: "#20262C", + zoneOutline: "#5A6870", zoneOutlineOpacity: 0.38, - previewFill: "#213126", + previewFill: "#253224", previewFillOpacity: 0.16, - previewOutline: "#657868", + previewOutline: "#8CAF5A", previewOutlineOpacity: 0.56, - selectedOutline: "#A78BFA", + selectedOutline: "#A5CF5A", selectedOutlineOpacity: 1.0, - surfaceAlt: "#141C17", - primary: "#A78BFA", // Vibrant light purple - text: "#EEF3EA", - textHalo: "#0E1412", + surfaceAlt: "#1B2026", + primary: "#A5CF5A", + markerAccent: "#59C6F6", + text: "#E8EDF2", + textHalo: "#1B2026", }, } as const; @@ -212,24 +226,57 @@ export const MapBrandPalette = NativeMapBrandPalette; // ─── Spacing & Radius ──────────────────────────────────────────────────────── export const BrandRadius = { + hard: 12, + medium: 18, + soft: 24, + pill: 999, card: 24, - cardSubtle: 18, // card - 6, for nested/inner elements + cardSubtle: 18, button: 20, - buttonSubtle: 14, // for smaller button-like elements + buttonSubtle: 14, input: 20, - pill: 999, - icon: 999, // circular for icon containers + icon: 999, + circle: 999, } as const; export const BrandSpacing = { + xxs: 2, xs: 4, sm: 8, md: 12, lg: 16, xl: 24, xxl: 32, - componentPadding: 14, // standard inner padding for cards/components - iconContainer: 38, // standard icon button/touch target size + componentPadding: 14, + iconContainer: 38, + 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/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/global.css b/src/global.css new file mode 100644 index 0000000..98a1240 --- /dev/null +++ b/src/global.css @@ -0,0 +1,128 @@ +@import "tailwindcss/theme.css" layer(theme); +@import "tailwindcss/preflight.css" layer(base); +@import "tailwindcss/utilities.css"; +@import "nativewind/theme"; + +/* ─── 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; + --spacing-lg: 16px; + --spacing-xl: 24px; + --spacing-xxl: 32px; + --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; + --radius-button-subtle: 14px; + --radius-input: 20px; + --radius-pill: 999px; + --radius-circle: 999px; +} + +/* ─── Brand Colors (Light Mode) ─────────────────────────────── */ +/* These map to: text-primary, bg-primary, border-primary, ring-primary, etc. */ +/* 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; + + /* Semantic */ + --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; + + /* Borders */ + --color-border: #ddd6ea; + --color-border-strong: #b8afcb; + + /* Text */ + --color-text: #181522; + --color-text-muted: #6d6580; + --color-text-micro: #8e86a0; + + /* Accent (overridable via vars() at runtime) */ + --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-surface: #121020; + --color-surface-alt: #1c1830; + --color-surface-elevated: #252238; + --color-app-bg: #0b0910; + + --color-border: #2d2850; + --color-border-strong: #3d3870; + + --color-text: #f5f3ff; + --color-text-muted: #a8a0c0; + --color-text-micro: #7870a0; + } +} + +/* ─── Platform Fonts ─────────────────────────────────────────── */ +@media ios { + :root { + --font-sans: system-ui; + --font-rounded: ui-rounded; + } +} + +@media android { + :root { + --font-sans: normal; + --font-rounded: normal; + } +} diff --git a/src/hooks/use-app-insets.ts b/src/hooks/use-app-insets.ts index 69e0d0a..f3d86fe 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; @@ -11,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 = Math.max(safeBottom, 16) + 16; + // 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, diff --git a/src/i18n/translations/en.ts b/src/i18n/translations/en.ts index 71d68f4..7ae3762 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.", @@ -426,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", @@ -621,7 +634,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 +655,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 +792,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.", @@ -1153,6 +1164,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", @@ -1357,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", @@ -1387,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 d61634a..8ef91b9 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 ייעודי.", @@ -396,6 +398,17 @@ const he = { zoneWaitingLabel: "האזור ממתין", zonePendingBody: "חפשו כתובת או השתמשו ב-GPS כדי לפתור את אזור השירות אוטומטית.", commandLabel: "שליטה", + enterManually: "הזנה ידנית", + backToSearch: "חזרה לחיפוש", + findByZip: "חיפוש לפי מיקוד", + fieldCity: "עיר", + fieldStreet: "רחוב", + fieldNumber: "מספר", + fieldFloor: "קומה", + fieldZipCode: "מיקוד", + fieldCityPlaceholder: "לדוגמה תל אביב", + fieldStreetPlaceholder: "לדוגמה אבן גבירול", + zipNotFound: "לא נמצאו כתובות למיקוד זה", }, calendar: { syncOff: "כבוי", @@ -599,14 +612,11 @@ const he = { prepTitle: "מה להכין מראש", prep: { documentTitle: "הכינו מסמך ממשלתי אמיתי", - documentBody: - "השתמשו בדרכון או בתעודת זהות שתואמים לשם החוקי שמשמש למשיכות.", + documentBody: "השתמשו בדרכון או בתעודת זהות שתואמים לשם החוקי שמשמש למשיכות.", faceTitle: "השתמשו בתאורה טובה לסריקת הפנים", - faceBody: - "Didit עשויה לבקש בדיקת סלפי או חיות כדי לוודא שהמסמך שייך לכם.", + faceBody: "Didit עשויה לבקש בדיקת סלפי או חיות כדי לוודא שהמסמך שייך לכם.", timeTitle: "פנו כמה דקות", - timeBody: - "הזרימה מהירה יותר כשהמסמך נקי, קריא, והמצלמה יציבה.", + timeBody: "הזרימה מהירה יותר כשהמסמך נקי, קריא, והמצלמה יציבה.", }, stepsTitle: "מה הבדיקה עושה", steps: { @@ -628,8 +638,7 @@ const he = { "כי Queue תומכת בפעילות בתשלום ובהגדרת משיכות, אנחנו צריכים לוודא מי מקבל כסף ולצמצם סיכוני הונאה לפני הפעלת משיכות.", why: { payoutsTitle: "משיכות דורשות זהות מאומתת", - payoutsBody: - "כך אנחנו קושרים משיכות ובדיקות חשבון לאדם אמיתי במקום לפרופיל אנונימי.", + payoutsBody: "כך אנחנו קושרים משיכות ובדיקות חשבון לאדם אמיתי במקום לפרופיל אנונימי.", fraudTitle: "בקרות הונאה בתשלומים בישראל מתחזקות", fraudBody: "בדיקות זהות עוזרות לצמצם השתלטות על חשבונות, כרטיסים גנובים, וניצול לרעה סביב תשלומים דיגיטליים.", @@ -662,8 +671,7 @@ const he = { in_review: "Didit קיבלה את ההגשה שלכם ובודקת אותה כעת. המסך הזה ימשיך לעקוב אחרי שתחזרו מהזרימה.", pending: "ההגשה התקבלה. אנחנו מחכים לתוצאת בדיקה סופית מ-Didit.", - in_progress: - "הניסיון האחרון לא הושלם. התחילו אימות מאובטח חדש כדי להמשיך.", + in_progress: "הניסיון האחרון לא הושלם. התחילו אימות מאובטח חדש כדי להמשיך.", abandoned: "זרימת האימות בוטלה לפני השלמה. התחילו שוב כשתהיו מוכנים.", expired: "תוקף סשן האימות פג. התחילו סשן חדש כדי להמשיך.", default: "השלימו אימות זהות כדי לפתוח KYC וגישה למשיכות.", @@ -745,10 +753,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.", @@ -1095,6 +1101,10 @@ const he = { emptyInstructorFreshTwo: "הלוח שקט כרגע.", emptyInstructorFreshThree: "נסו שוב עוד מעט.", emptyRefreshHint: "משכו לרענון כדי לקבל שורה חדשה.", + openArchive: "פתיחת ארכיון", + archiveSubtitle: "פניות קודמות ומשרות שעברו נשארות כאן.", + archiveEmpty: "עדיין אין משרות בארכיון.", + archiveAppliedOn: "הוגש ב־{{date}}", }, studioFeed: { eyebrow: "תפעול סטודיו", @@ -1306,6 +1316,8 @@ const he = { nextSubtitle: "השיעורים המאושרים הבאים שלך.", noUpcoming: "אין עדיין שיעורים קרובים. שווה לבדוק התאמות פתוחות.", emptySchedule: "לוח המשרות זמין כשתרצו את השיעור הבא.", + noJobsAvailable: "אין משרות זמינות", + noJobsHint: "בדקו שוב מאוחר יותר לפתיחות חדשות.", }, studio: { title: "בית", @@ -1336,6 +1348,8 @@ const he = { recentTitle: "משרות אחרונות", noRecent: "עדיין לא פורסמו משרות.", emptyBoard: 'פרסמו משמרת כדי להתחיל למלא את הלו"ז.', + noReviewJobs: "תור הבדיקה פנוי", + noReviewJobsHint: "כל המועמדים נבדקו.", }, }, explore: { 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/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 @@ -70,6 +70,7 @@ export function RoleTabsLayout({ appRole, badgeCountByRoute }: RoleTabsLayoutPro contentStyle={{ backgroundColor: sceneBackgroundColor }} > - + 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 new file mode 100644 index 0000000..5e1b154 --- /dev/null +++ b/src/tw/image.tsx @@ -0,0 +1,36 @@ +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"; + +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 ( + + ); +} + +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..7dbb5d0 --- /dev/null +++ b/src/tw/index.tsx @@ -0,0 +1,14 @@ +// NativeWind v5 handles className transformation via Metro import rewriting +// These re-export from react-native with automatic className support + +export { Link } from "expo-router"; +export { + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableHighlight, + View, +} from "react-native"; +export { createAnimatedComponent, default as Animated } from "react-native-reanimated"; 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; +} diff --git a/tsconfig.json b/tsconfig.json index 72a3c47..db56c93 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,7 +22,8 @@ "convex/**/*.ts", "convex/**/*.tsx", ".expo/types/**/*.ts", - "expo-env.d.ts" + "expo-env.d.ts", + "nativewind-env.d.ts" ], "exclude": [ "node_modules",