diff --git a/bun.lock b/bun.lock index ad90b790..174a3289 100644 --- a/bun.lock +++ b/bun.lock @@ -37,7 +37,7 @@ "@tailwindcss/vite": "^4.2.1", "@tanstack/react-query": "^5.103.2", "@tanstack/react-router": "^1.170.39", - "@tanstack/react-start": "^1.168.58", + "@tanstack/react-start": "1.168.60", "@tanstack/router-plugin": "^1.168.40", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -741,13 +741,13 @@ "@tanstack/react-router": ["@tanstack/react-router@1.170.39", "", { "dependencies": { "@tanstack/history": "1.162.4", "@tanstack/react-store": "^0.11.0", "@tanstack/router-core": "1.171.32", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-wLZi/hXW/kTeLlMIvI2VB8Oj38GLz8YkHmTM14lLScAzx79UZEwBHWMj6JZw/imuyuhrcmbe8KTFsFBHzJUftg=="], - "@tanstack/react-start": ["@tanstack/react-start@1.168.58", "", { "dependencies": { "@tanstack/react-router": "1.170.39", "@tanstack/react-start-client": "1.168.37", "@tanstack/react-start-rsc": "0.1.57", "@tanstack/react-start-server": "1.167.44", "@tanstack/router-utils": "1.162.3", "@tanstack/start-client-core": "1.170.32", "@tanstack/start-plugin-core": "1.171.47", "@tanstack/start-server-core": "1.169.37", "pathe": "^2.0.3" }, "peerDependencies": { "@rsbuild/core": "^2.0.0", "@vitejs/plugin-rsc": "*", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "vite": ">=7.0.0" }, "optionalPeers": ["@rsbuild/core", "@vitejs/plugin-rsc", "vite"] }, "sha512-PYIUFOsGp98QvqbW6GDkRqjAZNYJ1c+rA0pGtEwb6oDQeECnrbLij1NbGLPed/nVnROyBNwvq/+rAK3Hb3j9eg=="], + "@tanstack/react-start": ["@tanstack/react-start@1.168.60", "", { "dependencies": { "@tanstack/react-router": "1.170.41", "@tanstack/react-start-client": "1.168.39", "@tanstack/react-start-rsc": "0.1.59", "@tanstack/react-start-server": "1.167.46", "@tanstack/router-utils": "1.162.3", "@tanstack/start-client-core": "1.170.34", "@tanstack/start-plugin-core": "1.171.49", "@tanstack/start-server-core": "1.169.39", "pathe": "^2.0.3" }, "peerDependencies": { "@rsbuild/core": "^2.0.0", "@vitejs/plugin-rsc": "*", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "vite": ">=7.0.0" }, "optionalPeers": ["@rsbuild/core", "@vitejs/plugin-rsc", "vite"] }, "sha512-WMOM0Xlh2lPbVlbjYM6uOxOpiaWRPvODm/dwkLsGKq5tjyHOHZmKckJgwlrZl7OQUqFlOZHYj8bKUTgoeg8cUQ=="], - "@tanstack/react-start-client": ["@tanstack/react-start-client@1.168.37", "", { "dependencies": { "@tanstack/react-router": "1.170.39", "@tanstack/router-core": "1.171.32", "@tanstack/start-client-core": "1.170.32" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-koHPF9H/a94Akn33nSUKPEeRIs/JpZvR3ekYxKnUQshl6N24R5kTr7PVkT+qcPxqD+OAu2sKjdBqIXvKLVQjzQ=="], + "@tanstack/react-start-client": ["@tanstack/react-start-client@1.168.39", "", { "dependencies": { "@tanstack/react-router": "1.170.41", "@tanstack/router-core": "1.171.34", "@tanstack/start-client-core": "1.170.34" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-Uby0uo37JlQgUKHU0snDwH9nYiEeAjm9+7pUOFgycU/UszEILC2SO2DSCoNnCMuHKj9z+4URKzBYIP2exT9+wg=="], - "@tanstack/react-start-rsc": ["@tanstack/react-start-rsc@0.1.57", "", { "dependencies": { "@tanstack/react-router": "1.170.39", "@tanstack/router-core": "1.171.32", "@tanstack/router-utils": "1.162.3", "@tanstack/start-client-core": "1.170.32", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-plugin-core": "1.171.47", "@tanstack/start-storage-context": "1.167.34", "pathe": "^2.0.3" }, "peerDependencies": { "@rspack/core": ">=2.0.0-0", "@vitejs/plugin-rsc": ">=0.5.30", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "react-server-dom-rspack": ">=0.0.2" }, "optionalPeers": ["@rspack/core", "@vitejs/plugin-rsc", "react-server-dom-rspack"] }, "sha512-NIGJ8gktn6XDqDsUtW7onYWNLAO85ygpKXnfYdQZE3lQIFZLTzeI9Qcm67w1h4vRuxhsUNUSleMyhBqsmF8tLw=="], + "@tanstack/react-start-rsc": ["@tanstack/react-start-rsc@0.1.59", "", { "dependencies": { "@tanstack/react-router": "1.170.41", "@tanstack/router-core": "1.171.34", "@tanstack/router-utils": "1.162.3", "@tanstack/start-client-core": "1.170.34", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-plugin-core": "1.171.49", "@tanstack/start-storage-context": "1.167.36", "pathe": "^2.0.3" }, "peerDependencies": { "@rspack/core": ">=2.0.0-0", "@vitejs/plugin-rsc": ">=0.5.30", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "react-server-dom-rspack": ">=0.0.2" }, "optionalPeers": ["@rspack/core", "@vitejs/plugin-rsc", "react-server-dom-rspack"] }, "sha512-M2Lqxkk5C1zQncQs5paqX+/OLdOht51pg6D84YfLMrNsjhU/nzb6wd92MIja0mi5i23+L+fxLLjQYR2DObjBlw=="], - "@tanstack/react-start-server": ["@tanstack/react-start-server@1.167.44", "", { "dependencies": { "@tanstack/react-router": "1.170.39", "@tanstack/router-core": "1.171.32", "@tanstack/start-server-core": "1.169.37" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-eIdyRoq+rTNpCRx9xYJQaGHQmIUpTR80BGSmRy7pk/BbbdfXmyEgB570LDA/vAHySM1gh6PSTKfwGHBJ9FeqtQ=="], + "@tanstack/react-start-server": ["@tanstack/react-start-server@1.167.46", "", { "dependencies": { "@tanstack/react-router": "1.170.41", "@tanstack/router-core": "1.171.34", "@tanstack/start-server-core": "1.169.39" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-K7z8xBOEdp8u5CZCxWrbS0tFtk5XZGMdhssLeZlRZKlLDbX7BKLdMtJ7th5sR6QVt9MTUAg3LcoqvJMPSFF+lg=="], "@tanstack/react-store": ["@tanstack/react-store@0.11.1", "", { "dependencies": { "@tanstack/store": "0.11.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-HaIGKI3YLmjBYIvy5DFDY23oNaYZIsTZfngey07Uh5iLVJgM3bIGCnZeOFOqzjFld9JHWcaHJnasD/bKoGKwJQ=="], @@ -759,15 +759,15 @@ "@tanstack/router-utils": ["@tanstack/router-utils@1.162.3", "", { "dependencies": { "@babel/generator": "^7.29.8", "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "ansis": "^4.3.1", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.4", "pathe": "^2.0.3", "tinyglobby": "^0.2.17" } }, "sha512-Icb0xGuG1+54IV0WMLRcc3ErTx2HeJWyPG661FkQ8UT6guoBq1FJjFRqlG/JS0xi4mFFkBhvwO7tbLa32f3yxA=="], - "@tanstack/start-client-core": ["@tanstack/start-client-core@1.170.32", "", { "dependencies": { "@tanstack/router-core": "1.171.32", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-storage-context": "1.167.34", "seroval": "^1.6.2" } }, "sha512-kawgeg3Ej/JvGeEN7txFdVN9kXOST9wl7yPojOGESiomwiubHKRdYUS6sBskW91j44H+HirQUppP4+Ha7kjb4A=="], + "@tanstack/start-client-core": ["@tanstack/start-client-core@1.170.34", "", { "dependencies": { "@tanstack/router-core": "1.171.34", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-storage-context": "1.167.36", "seroval": "^1.6.7" } }, "sha512-LfwZSbau2MLLZ/uPAoO+NC9fK3J66c0wLhnNQqIa+Goqw7bXPjkHwlsjY7evlUz1AnyhhQ1Qab95v+VmP0TZ9A=="], "@tanstack/start-fn-stubs": ["@tanstack/start-fn-stubs@1.162.0", "", {}, "sha512-QWfUZ3Yo923tdQn38LyKMU8rcTw69zc+T4dAvgTWV4O56SqFRsGfS0lSWIMhJRwXIx/bvdi7nTUBDdZtTHtpTQ=="], - "@tanstack/start-plugin-core": ["@tanstack/start-plugin-core@1.171.47", "", { "dependencies": { "@babel/code-frame": "7.29.7", "@babel/core": "^7.29.7", "@babel/types": "^7.29.8", "@jridgewell/remapping": "^2.3.5", "@tanstack/router-core": "1.171.32", "@tanstack/router-generator": "1.167.38", "@tanstack/router-plugin": "1.168.40", "@tanstack/router-utils": "1.162.3", "@tanstack/start-server-core": "1.169.37", "exsolve": "^1.1.1", "lightningcss": "^1.33.0", "pathe": "^2.0.3", "picomatch": "^4.0.7", "seroval": "^1.6.2", "source-map": "^0.7.6", "srvx": "^0.11.22", "tinyglobby": "^0.2.17", "ufo": "^1.6.4", "vitefu": "^1.1.3", "xmlbuilder2": "^4.0.3", "zod": "^4.5.4" }, "peerDependencies": { "@rsbuild/core": "^2.0.0", "vite": ">=7.0.0" }, "optionalPeers": ["@rsbuild/core", "vite"] }, "sha512-yDduux0silP+81gZM+fY7OVebC8RcU8ud6v9E3+8fzn6wA0RnuFHbVC/UkN86z1pAaXbon3Ps8rqCq6S9mv8TA=="], + "@tanstack/start-plugin-core": ["@tanstack/start-plugin-core@1.171.49", "", { "dependencies": { "@babel/code-frame": "7.29.7", "@babel/core": "^7.29.7", "@babel/types": "^7.29.8", "@jridgewell/remapping": "^2.3.5", "@tanstack/router-core": "1.171.34", "@tanstack/router-generator": "1.167.40", "@tanstack/router-plugin": "1.168.42", "@tanstack/router-utils": "1.162.3", "@tanstack/start-server-core": "1.169.39", "exsolve": "^1.1.1", "lightningcss": "^1.33.0", "pathe": "^2.0.3", "picomatch": "^4.0.7", "seroval": "^1.6.7", "source-map": "^0.7.6", "srvx": "^0.11.22", "tinyglobby": "^0.2.17", "ufo": "^1.6.4", "vitefu": "^1.1.3", "xmlbuilder2": "^4.0.3", "zod": "^4.5.4" }, "peerDependencies": { "@rsbuild/core": "^2.0.0", "vite": ">=7.0.0" }, "optionalPeers": ["@rsbuild/core", "vite"] }, "sha512-HPOb7LnMc71k38cd5uvMybPPUU/Q/56KvSqQzSWz7cps1ZDFXaxUM6EFaFHLCdgTWqGexNGDdRE+sUVPVBM4Xg=="], - "@tanstack/start-server-core": ["@tanstack/start-server-core@1.169.37", "", { "dependencies": { "@tanstack/history": "1.162.4", "@tanstack/router-core": "1.171.32", "@tanstack/start-client-core": "1.170.32", "@tanstack/start-storage-context": "1.167.34", "fetchdts": "^0.1.6", "h3-v2": "npm:h3@2.0.1-rc.20", "seroval": "^1.6.2" } }, "sha512-CjYMXg2XISMEJt9UVR4lbMRsnRyWPrdyuF8iuJ4gxTc1sVNRciwMVpMPVGQG2KuIma3ewlNxw8+Tpx2tnS3zRA=="], + "@tanstack/start-server-core": ["@tanstack/start-server-core@1.169.39", "", { "dependencies": { "@tanstack/history": "1.162.4", "@tanstack/router-core": "1.171.34", "@tanstack/start-client-core": "1.170.34", "@tanstack/start-storage-context": "1.167.36", "fetchdts": "^0.1.6", "h3-v2": "npm:h3@2.0.1-rc.20", "seroval": "^1.6.7" } }, "sha512-pqqkxhXY46nuFSYt6TsMGyABiplUzHFHndKAlRRGC0fWDrJCH1koWxVMoxySa51ZETV79f/z73b5UZ1jfIkXwQ=="], - "@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.167.34", "", { "dependencies": { "@tanstack/router-core": "1.171.32" } }, "sha512-cRPE0kjt1CnOIhepmNOvRFwCGhDAnCRohPD3HmzqJJThPkixLBRuLDvIYp4hRPtM1TJ9hiKbYl24IxAhjZO5TA=="], + "@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.167.36", "", { "dependencies": { "@tanstack/router-core": "1.171.34" } }, "sha512-x+xCXK2GtFzY6NbXqAg8YpU6cwAjDfMeYSNzxfGzntpjbwBRHHrXeC/kYUnBBQCV7wK4kJ8vBBQGP0OyYTasUg=="], "@tanstack/store": ["@tanstack/store@0.11.1", "", {}, "sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA=="], @@ -1685,6 +1685,20 @@ "@tanstack/devtools-bundler-core/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + "@tanstack/react-start/@tanstack/react-router": ["@tanstack/react-router@1.170.41", "", { "dependencies": { "@tanstack/history": "1.162.4", "@tanstack/react-store": "^0.11.2", "@tanstack/router-core": "1.171.34", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-Mpvw8Wm5MGDbTqI8Yc9flLKxbOHwoWocv0H2aGKcgw2a017oK5nZWqa954P+DrUx/8fI98YNywnHP5XcD3tQ9w=="], + + "@tanstack/react-start-client/@tanstack/react-router": ["@tanstack/react-router@1.170.41", "", { "dependencies": { "@tanstack/history": "1.162.4", "@tanstack/react-store": "^0.11.2", "@tanstack/router-core": "1.171.34", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-Mpvw8Wm5MGDbTqI8Yc9flLKxbOHwoWocv0H2aGKcgw2a017oK5nZWqa954P+DrUx/8fI98YNywnHP5XcD3tQ9w=="], + + "@tanstack/react-start-client/@tanstack/router-core": ["@tanstack/router-core@1.171.34", "", { "dependencies": { "@tanstack/history": "1.162.4", "cookie-es": "^3.0.0", "seroval": "^1.6.7", "seroval-plugins": "^1.6.7" } }, "sha512-X+19pGuuLVPvvAvPYw9v/+ktRk5cCll8AtfpBpOhDRmVgUMrfqze0467xtq8YRVVs/mmo5rVtWn/7Tp32RDBKw=="], + + "@tanstack/react-start-rsc/@tanstack/react-router": ["@tanstack/react-router@1.170.41", "", { "dependencies": { "@tanstack/history": "1.162.4", "@tanstack/react-store": "^0.11.2", "@tanstack/router-core": "1.171.34", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-Mpvw8Wm5MGDbTqI8Yc9flLKxbOHwoWocv0H2aGKcgw2a017oK5nZWqa954P+DrUx/8fI98YNywnHP5XcD3tQ9w=="], + + "@tanstack/react-start-rsc/@tanstack/router-core": ["@tanstack/router-core@1.171.34", "", { "dependencies": { "@tanstack/history": "1.162.4", "cookie-es": "^3.0.0", "seroval": "^1.6.7", "seroval-plugins": "^1.6.7" } }, "sha512-X+19pGuuLVPvvAvPYw9v/+ktRk5cCll8AtfpBpOhDRmVgUMrfqze0467xtq8YRVVs/mmo5rVtWn/7Tp32RDBKw=="], + + "@tanstack/react-start-server/@tanstack/react-router": ["@tanstack/react-router@1.170.41", "", { "dependencies": { "@tanstack/history": "1.162.4", "@tanstack/react-store": "^0.11.2", "@tanstack/router-core": "1.171.34", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-Mpvw8Wm5MGDbTqI8Yc9flLKxbOHwoWocv0H2aGKcgw2a017oK5nZWqa954P+DrUx/8fI98YNywnHP5XcD3tQ9w=="], + + "@tanstack/react-start-server/@tanstack/router-core": ["@tanstack/router-core@1.171.34", "", { "dependencies": { "@tanstack/history": "1.162.4", "cookie-es": "^3.0.0", "seroval": "^1.6.7", "seroval-plugins": "^1.6.7" } }, "sha512-X+19pGuuLVPvvAvPYw9v/+ktRk5cCll8AtfpBpOhDRmVgUMrfqze0467xtq8YRVVs/mmo5rVtWn/7Tp32RDBKw=="], + "@tanstack/router-generator/prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="], "@tanstack/router-generator/zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], @@ -1693,10 +1707,28 @@ "@tanstack/router-utils/@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="], + "@tanstack/start-client-core/@tanstack/router-core": ["@tanstack/router-core@1.171.34", "", { "dependencies": { "@tanstack/history": "1.162.4", "cookie-es": "^3.0.0", "seroval": "^1.6.7", "seroval-plugins": "^1.6.7" } }, "sha512-X+19pGuuLVPvvAvPYw9v/+ktRk5cCll8AtfpBpOhDRmVgUMrfqze0467xtq8YRVVs/mmo5rVtWn/7Tp32RDBKw=="], + + "@tanstack/start-client-core/seroval": ["seroval@1.6.7", "", {}, "sha512-AeDcLh0yO2SFm9W71essgnSzLV9DI8ZH0x0knXn2DMnUZj728mpLbxjlbB6IqKCmqh8JA3cEqRyGoNkt584JcQ=="], + + "@tanstack/start-plugin-core/@tanstack/router-core": ["@tanstack/router-core@1.171.34", "", { "dependencies": { "@tanstack/history": "1.162.4", "cookie-es": "^3.0.0", "seroval": "^1.6.7", "seroval-plugins": "^1.6.7" } }, "sha512-X+19pGuuLVPvvAvPYw9v/+ktRk5cCll8AtfpBpOhDRmVgUMrfqze0467xtq8YRVVs/mmo5rVtWn/7Tp32RDBKw=="], + + "@tanstack/start-plugin-core/@tanstack/router-generator": ["@tanstack/router-generator@1.167.40", "", { "dependencies": { "@babel/types": "^7.29.8", "@tanstack/router-core": "1.171.34", "@tanstack/router-utils": "1.162.3", "@tanstack/virtual-file-routes": "1.162.0", "jiti": "^2.7.0", "magic-string": "^0.30.21", "prettier": "^3.9.6", "zod": "^4.5.4" } }, "sha512-WkEueqEvsBBudzO0fx1nWfOmRNW8OAvBoVABTGls9bAWzjdC+KDGHvjCNrvn88ofs1F6gqL8aWnzKCbxW3dCyQ=="], + + "@tanstack/start-plugin-core/@tanstack/router-plugin": ["@tanstack/router-plugin@1.168.42", "", { "dependencies": { "@babel/core": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "@tanstack/router-core": "1.171.34", "@tanstack/router-generator": "1.167.40", "@tanstack/router-utils": "1.162.3", "chokidar": "^5.0.0", "unplugin": "^3.3.0", "zod": "^4.5.4" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2 || ^2.0.0", "@tanstack/react-router": "^1.170.41", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", "vite-plugin-solid": "^2.11.10 || ^3.0.0-0", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-vAR5WXJh/ErseEWQftDhsJcr2c++ucRQ6FbqN3ygMO44+ZxxRfW3sIpZmddHROVSmBk8jc6MrwjTPNjVhd62Vw=="], + + "@tanstack/start-plugin-core/seroval": ["seroval@1.6.7", "", {}, "sha512-AeDcLh0yO2SFm9W71essgnSzLV9DI8ZH0x0knXn2DMnUZj728mpLbxjlbB6IqKCmqh8JA3cEqRyGoNkt584JcQ=="], + "@tanstack/start-plugin-core/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "@tanstack/start-plugin-core/zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], + "@tanstack/start-server-core/@tanstack/router-core": ["@tanstack/router-core@1.171.34", "", { "dependencies": { "@tanstack/history": "1.162.4", "cookie-es": "^3.0.0", "seroval": "^1.6.7", "seroval-plugins": "^1.6.7" } }, "sha512-X+19pGuuLVPvvAvPYw9v/+ktRk5cCll8AtfpBpOhDRmVgUMrfqze0467xtq8YRVVs/mmo5rVtWn/7Tp32RDBKw=="], + + "@tanstack/start-server-core/seroval": ["seroval@1.6.7", "", {}, "sha512-AeDcLh0yO2SFm9W71essgnSzLV9DI8ZH0x0knXn2DMnUZj728mpLbxjlbB6IqKCmqh8JA3cEqRyGoNkt584JcQ=="], + + "@tanstack/start-storage-context/@tanstack/router-core": ["@tanstack/router-core@1.171.34", "", { "dependencies": { "@tanstack/history": "1.162.4", "cookie-es": "^3.0.0", "seroval": "^1.6.7", "seroval-plugins": "^1.6.7" } }, "sha512-X+19pGuuLVPvvAvPYw9v/+ktRk5cCll8AtfpBpOhDRmVgUMrfqze0467xtq8YRVVs/mmo5rVtWn/7Tp32RDBKw=="], + "@types/babel__core/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], "@types/babel__core/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], @@ -1795,6 +1827,38 @@ "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "@tanstack/react-start-client/@tanstack/react-router/@tanstack/react-store": ["@tanstack/react-store@0.11.2", "", { "dependencies": { "@tanstack/store": "0.11.2", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-oj5O5DmiMWE+ih+SioX2ayC1kbK7d3fpLx0B75fPJr9nY3zU+hoW010yr64qXsY/9fyiLVE2bn5mGNV3owm8RQ=="], + + "@tanstack/react-start-client/@tanstack/router-core/seroval": ["seroval@1.6.7", "", {}, "sha512-AeDcLh0yO2SFm9W71essgnSzLV9DI8ZH0x0knXn2DMnUZj728mpLbxjlbB6IqKCmqh8JA3cEqRyGoNkt584JcQ=="], + + "@tanstack/react-start-client/@tanstack/router-core/seroval-plugins": ["seroval-plugins@1.6.7", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4Nk35ttD3DTDJW4hgw5StsVAPeU6qnDFnULAouw6tQ7oLTV/ICXrWpsXo2EE52eSP2joUMazbVf52mFEcADqRw=="], + + "@tanstack/react-start-rsc/@tanstack/react-router/@tanstack/react-store": ["@tanstack/react-store@0.11.2", "", { "dependencies": { "@tanstack/store": "0.11.2", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-oj5O5DmiMWE+ih+SioX2ayC1kbK7d3fpLx0B75fPJr9nY3zU+hoW010yr64qXsY/9fyiLVE2bn5mGNV3owm8RQ=="], + + "@tanstack/react-start-rsc/@tanstack/router-core/seroval": ["seroval@1.6.7", "", {}, "sha512-AeDcLh0yO2SFm9W71essgnSzLV9DI8ZH0x0knXn2DMnUZj728mpLbxjlbB6IqKCmqh8JA3cEqRyGoNkt584JcQ=="], + + "@tanstack/react-start-rsc/@tanstack/router-core/seroval-plugins": ["seroval-plugins@1.6.7", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4Nk35ttD3DTDJW4hgw5StsVAPeU6qnDFnULAouw6tQ7oLTV/ICXrWpsXo2EE52eSP2joUMazbVf52mFEcADqRw=="], + + "@tanstack/react-start-server/@tanstack/react-router/@tanstack/react-store": ["@tanstack/react-store@0.11.2", "", { "dependencies": { "@tanstack/store": "0.11.2", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-oj5O5DmiMWE+ih+SioX2ayC1kbK7d3fpLx0B75fPJr9nY3zU+hoW010yr64qXsY/9fyiLVE2bn5mGNV3owm8RQ=="], + + "@tanstack/react-start-server/@tanstack/router-core/seroval": ["seroval@1.6.7", "", {}, "sha512-AeDcLh0yO2SFm9W71essgnSzLV9DI8ZH0x0knXn2DMnUZj728mpLbxjlbB6IqKCmqh8JA3cEqRyGoNkt584JcQ=="], + + "@tanstack/react-start-server/@tanstack/router-core/seroval-plugins": ["seroval-plugins@1.6.7", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4Nk35ttD3DTDJW4hgw5StsVAPeU6qnDFnULAouw6tQ7oLTV/ICXrWpsXo2EE52eSP2joUMazbVf52mFEcADqRw=="], + + "@tanstack/react-start/@tanstack/react-router/@tanstack/react-store": ["@tanstack/react-store@0.11.2", "", { "dependencies": { "@tanstack/store": "0.11.2", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-oj5O5DmiMWE+ih+SioX2ayC1kbK7d3fpLx0B75fPJr9nY3zU+hoW010yr64qXsY/9fyiLVE2bn5mGNV3owm8RQ=="], + + "@tanstack/react-start/@tanstack/react-router/@tanstack/router-core": ["@tanstack/router-core@1.171.34", "", { "dependencies": { "@tanstack/history": "1.162.4", "cookie-es": "^3.0.0", "seroval": "^1.6.7", "seroval-plugins": "^1.6.7" } }, "sha512-X+19pGuuLVPvvAvPYw9v/+ktRk5cCll8AtfpBpOhDRmVgUMrfqze0467xtq8YRVVs/mmo5rVtWn/7Tp32RDBKw=="], + + "@tanstack/start-client-core/@tanstack/router-core/seroval-plugins": ["seroval-plugins@1.6.7", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4Nk35ttD3DTDJW4hgw5StsVAPeU6qnDFnULAouw6tQ7oLTV/ICXrWpsXo2EE52eSP2joUMazbVf52mFEcADqRw=="], + + "@tanstack/start-plugin-core/@tanstack/router-core/seroval-plugins": ["seroval-plugins@1.6.7", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4Nk35ttD3DTDJW4hgw5StsVAPeU6qnDFnULAouw6tQ7oLTV/ICXrWpsXo2EE52eSP2joUMazbVf52mFEcADqRw=="], + + "@tanstack/start-server-core/@tanstack/router-core/seroval-plugins": ["seroval-plugins@1.6.7", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4Nk35ttD3DTDJW4hgw5StsVAPeU6qnDFnULAouw6tQ7oLTV/ICXrWpsXo2EE52eSP2joUMazbVf52mFEcADqRw=="], + + "@tanstack/start-storage-context/@tanstack/router-core/seroval": ["seroval@1.6.7", "", {}, "sha512-AeDcLh0yO2SFm9W71essgnSzLV9DI8ZH0x0knXn2DMnUZj728mpLbxjlbB6IqKCmqh8JA3cEqRyGoNkt584JcQ=="], + + "@tanstack/start-storage-context/@tanstack/router-core/seroval-plugins": ["seroval-plugins@1.6.7", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4Nk35ttD3DTDJW4hgw5StsVAPeU6qnDFnULAouw6tQ7oLTV/ICXrWpsXo2EE52eSP2joUMazbVf52mFEcADqRw=="], + "@types/ws/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "buffer-image-size/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], @@ -1833,6 +1897,18 @@ "workbox-build/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "@tanstack/react-start-client/@tanstack/react-router/@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.11.2", "", {}, "sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q=="], + + "@tanstack/react-start-rsc/@tanstack/react-router/@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.11.2", "", {}, "sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q=="], + + "@tanstack/react-start-server/@tanstack/react-router/@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.11.2", "", {}, "sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q=="], + + "@tanstack/react-start/@tanstack/react-router/@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.11.2", "", {}, "sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q=="], + + "@tanstack/react-start/@tanstack/react-router/@tanstack/router-core/seroval": ["seroval@1.6.7", "", {}, "sha512-AeDcLh0yO2SFm9W71essgnSzLV9DI8ZH0x0knXn2DMnUZj728mpLbxjlbB6IqKCmqh8JA3cEqRyGoNkt584JcQ=="], + + "@tanstack/react-start/@tanstack/react-router/@tanstack/router-core/seroval-plugins": ["seroval-plugins@1.6.7", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4Nk35ttD3DTDJW4hgw5StsVAPeU6qnDFnULAouw6tQ7oLTV/ICXrWpsXo2EE52eSP2joUMazbVf52mFEcADqRw=="], + "filelist/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], } } diff --git a/e2e/and66.e2e.ts b/e2e/and66.e2e.ts index d4ece139..c3a75c78 100644 --- a/e2e/and66.e2e.ts +++ b/e2e/and66.e2e.ts @@ -43,7 +43,7 @@ test("AND-66 first-run landing keeps activation clear on a narrow phone", async ).toBeVisible(); await expect( page.getByText( - "One precise workspace for your U of T timetable, the time between classes, and source-backed campus context where available.", + "Plan classes, buildings, routes, and time between classes across the Mississauga campus.", ), ).toBeVisible(); await expect(page.getByRole("button", { name: "Try a demo" })).toBeVisible(); diff --git a/e2e/core.e2e.ts b/e2e/core.e2e.ts index c204b1ff..c5c90c28 100644 --- a/e2e/core.e2e.ts +++ b/e2e/core.e2e.ts @@ -13,8 +13,12 @@ const twoTermFixturePath = path.join( test("landing page is usable without an account", async ({ page }, testInfo) => { const guard = watchForAppFailures(page, String(testInfo.project.use.baseURL)); await expectLanding(page); - await expect(page).toHaveTitle("Gapwise — University Timetable & Campus Navigation"); - await expect(page.getByText("For University of Toronto", { exact: true })).toBeVisible(); + await expect(page).toHaveTitle( + "Gapwise for University of Toronto Mississauga — Timetable & Campus Navigation", + ); + await expect( + page.getByText("For University of Toronto Mississauga", { exact: true }), + ).toBeVisible(); await expect(page.getByRole("button", { name: "Import ACORN" })).toBeVisible(); guard.assertClean(); }); @@ -241,44 +245,27 @@ test("campus explorer supports public building deep links and local search", asy guard.assertClean(); }); -test("a first-time visitor can browse buildings on all three campus maps", async ({ +test("a first-time visitor can browse buildings on the campus edition", async ({ page, }, testInfo) => { test.skip( !["chromium", "mobile-chromium"].includes(testInfo.project.name), - "tri-campus map journey runs in desktop and phone Chromium", + "campus map journey runs in desktop and phone Chromium", ); const guard = watchForAppFailures(page, String(testInfo.project.use.baseURL)); await page.goto("/route"); - await expect(page.getByRole("heading", { name: "Choose a campus map" })).toBeVisible(); - - for (const [campus, campusId, query, building, buildingCode] of [ - ["UTSG", "utsg", "Bahen", "Bahen Centre for Information Technology", "BA"], - ["UTSC", "utsc", "Science Wing", "Science Wing", "SW"], - ["UTM", "utm", "Maanjiwe", "Maanjiwe nendamowinan", "MN"], - ]) { - await page.getByRole("button", { name: campus, exact: true }).click(); - const search = page.getByRole("searchbox", { name: `Search ${campus} buildings` }); - await expect(search).toBeVisible(); - await search.fill(query); - await search.press("Enter"); - await expect(page.getByRole("heading", { name: building })).toBeVisible(); - expect(new URL(page.url()).searchParams.get("campus")).toBe(campusId); - expect(new URL(page.url()).searchParams.get("building")).toBe(buildingCode); - - if (campus === "UTSG") { - await page.reload(); - await expect(page.getByRole("heading", { name: building })).toBeVisible(); - await expect(page.getByRole("searchbox", { name: "Search UTSG buildings" })).toBeVisible(); - } - } + const search = page.getByRole("searchbox", { name: "Search UTM buildings" }); + await expect(search).toBeVisible(); + await search.fill("Maanjiwe"); + await search.press("Enter"); + await expect(page.getByRole("heading", { name: "Maanjiwe nendamowinan" })).toBeVisible(); + expect(new URL(page.url()).searchParams.get("campus")).toBe("utm"); + expect(new URL(page.url()).searchParams.get("building")).toBe("MN"); - await page.goBack(); - await expect(page.getByRole("heading", { name: "Science Wing" })).toBeVisible(); - await expect(page.getByRole("searchbox", { name: "Search UTSC buildings" })).toBeVisible(); - await page.goForward(); + await page.reload(); await expect(page.getByRole("heading", { name: "Maanjiwe nendamowinan" })).toBeVisible(); + await expect(page.getByRole("searchbox", { name: "Search UTM buildings" })).toBeVisible(); guard.assertClean(); }); diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 624392af..81f639ef 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -4,9 +4,21 @@ export function isMobileProject(projectName: string) { return projectName.startsWith("mobile-"); } +export function editionUrl(baseURL: string, host: string, path = "/") { + const url = new URL(path, baseURL); + const isLocal = + url.hostname === "localhost" || + url.hostname === "127.0.0.1" || + url.hostname.endsWith(".localhost"); + if (isLocal) url.hostname = `${host}.localhost`; + else url.searchParams.set("university", host); + return url.toString(); +} + export function watchForAppFailures(page: Page, baseURL: string) { const failures: string[] = []; const appOrigin = new URL(baseURL).origin; + const appUrl = new URL(baseURL); const ignoredLocalInstrumentationPaths = [ "/_vercel/insights/", "/_vercel/speed-insights/", @@ -14,9 +26,16 @@ export function watchForAppFailures(page: Page, baseURL: string) { ]; const isIgnoredLocalInstrumentationRequest = (url: URL) => - url.origin === appOrigin && + isFirstParty(url) && ignoredLocalInstrumentationPaths.some((path) => url.pathname.startsWith(path)); + const isFirstParty = (url: URL) => + url.origin === appOrigin || + (appUrl.hostname.endsWith(".localhost") && + url.hostname.endsWith(".localhost") && + url.protocol === appUrl.protocol && + url.port === appUrl.port); + page.on("pageerror", (error) => { failures.push(`pageerror: ${error.message}`); }); @@ -34,7 +53,7 @@ export function watchForAppFailures(page: Page, baseURL: string) { page.on("response", (response) => { const url = new URL(response.url()); if (isIgnoredLocalInstrumentationRequest(url)) return; - if (url.origin === appOrigin && response.status() >= 400) { + if (isFirstParty(url) && response.status() >= 400) { failures.push(`HTTP ${response.status()}: ${url.pathname}`); } }); @@ -42,7 +61,7 @@ export function watchForAppFailures(page: Page, baseURL: string) { page.on("requestfailed", (request) => { const url = new URL(request.url()); if (isIgnoredLocalInstrumentationRequest(url)) return; - if (url.origin !== appOrigin) return; + if (!isFirstParty(url)) return; failures.push(`request failed: ${url.pathname} (${request.failure()?.errorText ?? "unknown"})`); }); diff --git a/e2e/mcgill-university.e2e.ts b/e2e/mcgill-university.e2e.ts index 58021a28..3d408b99 100644 --- a/e2e/mcgill-university.e2e.ts +++ b/e2e/mcgill-university.e2e.ts @@ -1,5 +1,5 @@ import { expect, test } from "@playwright/test"; -import { watchForAppFailures } from "./helpers"; +import { editionUrl, watchForAppFailures } from "./helpers"; test("McGill calendar import preserves downtown campus search and routing", async ({ page, @@ -9,7 +9,7 @@ test("McGill calendar import preserves downtown campus search and routing", asyn if (!baseURL) throw new Error("Playwright baseURL is required"); const failures = watchForAppFailures(page, baseURL); - await page.goto("/?university=mcgill&campus=mcgill-downtown"); + await page.goto(editionUrl(baseURL, "mcgill")); await expect(page.getByText("For McGill University", { exact: true })).toBeVisible(); await expect(page.locator(".brand-scope-pill").first()).toHaveText("McGill"); @@ -37,7 +37,7 @@ test("McGill calendar import preserves downtown campus search and routing", asyn await search.fill("LEA"); await expect(page.getByTestId("building-search-result").first()).toContainText("Leacock"); - await page.goto("/route?university=mcgill&campus=mcgill-downtown"); + await page.goto(editionUrl(baseURL, "mcgill", "/route")); await expect(page.locator(".brand-scope-pill").first()).toHaveText("McGill"); await expect(page.getByRole("searchbox", { name: "Search McGill buildings" })).toBeVisible(); failures.assertClean(); @@ -48,7 +48,7 @@ test("McGill demo remains usable on the mobile campus map", async ({ page, baseU if (!baseURL) throw new Error("Playwright baseURL is required"); const failures = watchForAppFailures(page, baseURL); - await page.goto("/?university=mcgill&campus=mcgill-downtown"); + await page.goto(editionUrl(baseURL, "mcgill")); await page.getByRole("button", { name: "Try a demo" }).click(); await expect(page).toHaveURL(/\/timetable/); await page.getByRole("group", { name: "Weekday" }).getByRole("button", { name: /Mon/ }).click(); diff --git a/e2e/site-roles.e2e.ts b/e2e/site-roles.e2e.ts new file mode 100644 index 00000000..fc5cbb0c --- /dev/null +++ b/e2e/site-roles.e2e.ts @@ -0,0 +1,150 @@ +import { expect, test } from "@playwright/test"; +import { watchForAppFailures } from "./helpers"; + +test("global homepage presents the multi-university Gapwise ecosystem", async ({ + page, + baseURL, +}) => { + test.skip(test.info().project.name !== "chromium"); + if (!baseURL) throw new Error("Playwright baseURL is required"); + const failures = watchForAppFailures(page, baseURL); + + await page.goto("/?site=global"); + await expect( + page.getByRole("heading", { name: "Make every gap on campus count." }), + ).toBeVisible(); + await expect(page.locator(".global-home-facts")).toContainText("14supported universities"); + await expect(page.getByRole("link", { name: /Choose your university/ })).toBeVisible(); + await expect(page.getByText("For University of Toronto", { exact: true })).toHaveCount(0); + await expect(page.getByText("This edition", { exact: true })).toHaveCount(0); + await expect(page.getByRole("button", { name: /Search campus and classes/ })).toHaveCount(0); + failures.assertClean(); +}); + +test("University of Toronto hub links to three distinct campus editions", async ({ + page, + baseURL, +}) => { + test.skip(test.info().project.name !== "chromium"); + if (!baseURL) throw new Error("Playwright baseURL is required"); + const failures = watchForAppFailures(page, baseURL); + + await page.goto("/?site=uoft-hub"); + await expect( + page.getByText("Gapwise for the University of Toronto", { exact: true }), + ).toBeVisible(); + await expect(page.getByRole("button", { name: /Search campus and classes/ })).toHaveCount(0); + for (const [name, host] of [ + ["University of Toronto Mississauga", "https://utm.gapwise.ca"], + ["University of Toronto St. George", "https://utsg.gapwise.ca"], + ["University of Toronto Scarborough", "https://utsc.gapwise.ca"], + ] as const) { + await expect(page.getByRole("link", { name: new RegExp(name) })).toHaveAttribute("href", host); + } + failures.assertClean(); +}); + +test("U of T campus editions retain campus-scoped import experiences", async ({ + page, + baseURL, +}) => { + test.skip(test.info().project.name !== "chromium"); + if (!baseURL) throw new Error("Playwright baseURL is required"); + const failures = watchForAppFailures(page, baseURL); + + for (const [campus, name] of [ + ["utm", "University of Toronto Mississauga"], + ["utsg", "University of Toronto St. George"], + ["utsc", "University of Toronto Scarborough"], + ] as const) { + await page.goto(`/?campus=${campus}`); + await expect(page.getByText(`For ${name}`, { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Import ACORN" })).toBeVisible(); + } + failures.assertClean(); +}); + +for (const width of [320, 360, 375, 390, 393, 430]) { + test(`mobile public header and global hero fit at ${width}px`, async ({ page, baseURL }) => { + test.skip(test.info().project.name !== "chromium"); + if (!baseURL) throw new Error("Playwright baseURL is required"); + await page.setViewportSize({ width, height: 844 }); + const failures = watchForAppFailures(page, baseURL); + + await page.goto("/?site=global"); + await expect( + page.getByRole("heading", { name: "Make every gap on campus count." }), + ).toBeVisible(); + const layout = await page.evaluate(() => { + const brand = document.querySelector(".brand-lockup"); + const heading = document.querySelector("#global-home-title"); + const brandRect = brand?.getBoundingClientRect(); + const headingRect = heading?.getBoundingClientRect(); + return { + viewportWidth: window.innerWidth, + documentWidth: document.documentElement.scrollWidth, + bodyWidth: document.body.scrollWidth, + brandText: brand?.innerText.trim(), + brandLeft: brandRect?.left, + brandRight: brandRect?.right, + headingHeight: headingRect?.height, + }; + }); + expect(layout.brandText).toBe("Gapwise"); + expect(layout.documentWidth).toBeLessThanOrEqual(layout.viewportWidth); + expect(layout.bodyWidth).toBeLessThanOrEqual(layout.viewportWidth); + expect(layout.brandLeft).toBeGreaterThanOrEqual(0); + expect(layout.brandRight).toBeLessThanOrEqual(width); + expect(layout.headingHeight).toBeLessThan(260); + await page.screenshot({ + path: test.info().outputPath(`global-${width}.png`), + fullPage: false, + }); + failures.assertClean(); + }); +} + +test("narrow UTM header preserves the full brand and contains its controls", async ({ + page, + baseURL, +}) => { + test.skip(test.info().project.name !== "chromium"); + if (!baseURL) throw new Error("Playwright baseURL is required"); + await page.setViewportSize({ width: 320, height: 844 }); + const failures = watchForAppFailures(page, baseURL); + + await page.goto("/?campus=utm"); + await expect( + page.getByText("For University of Toronto Mississauga", { exact: true }), + ).toBeVisible(); + const header = await page.evaluate(() => { + const brand = document.querySelector(".brand-lockup"); + const brandRect = brand?.getBoundingClientRect(); + const controls = [...document.querySelectorAll(".desktop-app-header button")] + .filter((control) => control.getClientRects().length > 0) + .map((control) => { + const rect = control.getBoundingClientRect(); + return { left: rect.left, right: rect.right, width: rect.width, height: rect.height }; + }); + return { + viewportWidth: window.innerWidth, + documentWidth: document.documentElement.scrollWidth, + brandText: brand?.innerText.trim(), + brandLeft: brandRect?.left, + brandRight: brandRect?.right, + controls, + }; + }); + expect(header.brandText).toBe("Gapwise"); + expect(header.documentWidth).toBeLessThanOrEqual(header.viewportWidth); + expect(header.brandLeft).toBeGreaterThanOrEqual(0); + expect(header.brandRight).toBeLessThanOrEqual(320); + for (const control of header.controls) { + expect(control.left).toBeGreaterThanOrEqual(0); + expect(control.right).toBeLessThanOrEqual(320); + expect(control.width).toBeGreaterThanOrEqual(44); + expect(control.height).toBeGreaterThanOrEqual(44); + } + await page.screenshot({ path: test.info().outputPath("utm-320.png"), fullPage: false }); + failures.assertClean(); +}); diff --git a/e2e/ubc-university.e2e.ts b/e2e/ubc-university.e2e.ts index e89cacb7..3d60a4e1 100644 --- a/e2e/ubc-university.e2e.ts +++ b/e2e/ubc-university.e2e.ts @@ -1,6 +1,6 @@ import { expect, test } from "@playwright/test"; import { readFileSync } from "node:fs"; -import { watchForAppFailures } from "./helpers"; +import { editionUrl, watchForAppFailures } from "./helpers"; const workdayFixture = readFileSync( new URL("../tests/fixtures/ubc-workday-sanitized.tsv", import.meta.url), @@ -15,7 +15,7 @@ test("UBC Workday paste import preserves the Vancouver campus through timetable if (!baseURL) throw new Error("Playwright baseURL is required"); const failures = watchForAppFailures(page, baseURL); - await page.goto("/?university=ubc&campus=ubc-vancouver"); + await page.goto(editionUrl(baseURL, "ubc")); await expect(page.getByText("For University of British Columbia", { exact: true })).toBeVisible(); await expect(page.locator(".brand-scope-pill").first()).toHaveText("UBC"); @@ -57,7 +57,7 @@ test("UBC Workday paste import preserves the Vancouver campus through timetable await expect(page.locator(".brand-scope-pill").first()).toHaveText("UBC"); await expect(page.getByRole("searchbox", { name: "Search UBC buildings" })).toBeVisible(); - await page.goto("/route?university=ubc&campus=ubc-vancouver"); + await page.goto(editionUrl(baseURL, "ubc", "/route")); await expect(page.locator(".brand-scope-pill").first()).toHaveText("UBC"); await expect(page.getByRole("searchbox", { name: "Search UBC buildings" })).toBeVisible(); failures.assertClean(); @@ -68,7 +68,7 @@ test("UBC has its own demo timetable and usable mobile campus map", async ({ pag if (!baseURL) throw new Error("Playwright baseURL is required"); const failures = watchForAppFailures(page, baseURL); - await page.goto("/?university=ubc&campus=ubc-vancouver"); + await page.goto(editionUrl(baseURL, "ubc")); await expect(page.getByText("For University of British Columbia", { exact: true })).toBeVisible(); await page.getByRole("button", { name: "Try a demo" }).click(); await expect(page).toHaveURL(/\/timetable/); diff --git a/e2e/university-context.e2e.ts b/e2e/university-context.e2e.ts index ce0933fe..871c677f 100644 --- a/e2e/university-context.e2e.ts +++ b/e2e/university-context.e2e.ts @@ -1,5 +1,5 @@ import { expect, test } from "@playwright/test"; -import { watchForAppFailures } from "./helpers"; +import { editionUrl, watchForAppFailures } from "./helpers"; test("Carleton uses the canonical product screens and campus dataset", async ({ page, @@ -9,7 +9,7 @@ test("Carleton uses the canonical product screens and campus dataset", async ({ if (!baseURL) throw new Error("Playwright baseURL is required"); const failures = watchForAppFailures(page, baseURL); - await page.goto("/?university=carleton"); + await page.goto(editionUrl(baseURL, "carleton")); await expect(page.getByText("For Carleton University", { exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: /Import Carleton/ })).toBeVisible(); await page.getByRole("button", { name: "Try a demo" }).click(); @@ -39,12 +39,14 @@ test("Carleton uses the canonical product screens and campus dataset", async ({ failures.assertClean(); }); -test("UofT local default remains University of Toronto", async ({ page, baseURL }) => { +test("explicit UTM local host preserves the campus edition", async ({ page, baseURL }) => { test.skip(test.info().project.name !== "chromium"); if (!baseURL) throw new Error("Playwright baseURL is required"); const failures = watchForAppFailures(page, baseURL); await page.goto("/"); - await expect(page).toHaveTitle("Gapwise — University Timetable & Campus Navigation"); + await expect(page).toHaveTitle( + "Gapwise for University of Toronto Mississauga — Timetable & Campus Navigation", + ); await expect(page.getByRole("button", { name: "Import ACORN" })).toBeVisible(); failures.assertClean(); }); @@ -57,7 +59,7 @@ test("Carleton calendar import feeds timetable, gaps, and Day Route on a tablet" if (!baseURL) throw new Error("Playwright baseURL is required"); await page.setViewportSize({ width: 820, height: 1180 }); const failures = watchForAppFailures(page, baseURL); - await page.goto("/?university=carleton"); + await page.goto(editionUrl(baseURL, "carleton")); const calendar = [ "BEGIN:VCALENDAR", "VERSION:2.0", @@ -130,7 +132,7 @@ test("Direct URL refresh preserves university context in local development", asy if (!baseURL) throw new Error("Playwright baseURL is required"); const failures = watchForAppFailures(page, baseURL); - await page.goto("/?university=carleton"); + await page.goto(editionUrl(baseURL, "carleton")); await expect(page.getByText("For Carleton University", { exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: /Import Carleton/ })).toBeVisible(); @@ -150,7 +152,7 @@ test("TMU, Queen's, and Laurier use canonical product screens and single-campus const failures = watchForAppFailures(page, baseURL); // TMU - await page.goto("/?university=tmu"); + await page.goto(editionUrl(baseURL, "tmu")); await expect( page.getByText("For Toronto Metropolitan University", { exact: true }), ).toBeVisible(); @@ -165,7 +167,7 @@ test("TMU, Queen's, and Laurier use canonical product screens and single-campus await expect(page.getByText("CPS 109").first()).toBeVisible(); // Queen's - await page.goto("/?university=queens"); + await page.goto(editionUrl(baseURL, "queens")); await expect(page.getByText("For Queen's University", { exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: /Import (SOLUS|pasted schedule)/ })).toBeVisible(); await page.getByRole("button", { name: "Try a demo" }).click(); @@ -176,7 +178,7 @@ test("TMU, Queen's, and Laurier use canonical product screens and single-campus await expect(page.getByText("CISC 121").first()).toBeVisible(); // Laurier - await page.goto("/?university=laurier"); + await page.goto(editionUrl(baseURL, "laurier")); await expect(page.getByText("For Wilfrid Laurier University", { exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: /Import (LORIS|pasted schedule)/ })).toBeVisible(); await page.getByRole("button", { name: "Try a demo" }).click(); diff --git a/e2e/uoft-campus-inference.e2e.ts b/e2e/uoft-campus-inference.e2e.ts index 1cceb149..3c0eabdd 100644 --- a/e2e/uoft-campus-inference.e2e.ts +++ b/e2e/uoft-campus-inference.e2e.ts @@ -72,7 +72,7 @@ test("ACORN building evidence activates the correct UTM, UTSG, and UTSC products failures.assertClean(); }); -test("mixed and ambiguous ACORN locations require an explicit campus choice", async ({ +test("mixed and ambiguous ACORN locations preserve the explicit campus edition", async ({ page, baseURL, }) => { @@ -83,8 +83,8 @@ test("mixed and ambiguous ACORN locations require an explicit campus choice", as for (const name of ["acorn-mixed-campus-sanitized.ics", "acorn-ambiguous-campus-sanitized.ics"]) { await importFixture(page, name); await openDayRoute(page, false); - await expect(page.getByRole("heading", { name: "Choose a campus map" })).toBeVisible(); - await expect(page.getByRole("button", { name: "UTM", exact: true })).toBeVisible(); + await expect(page.getByRole("searchbox", { name: "Search UTM buildings" })).toBeVisible(); + expect(new URL(page.url()).hostname).toBe(new URL(baseURL).hostname); expect(new URL(page.url()).searchParams.get("campus")).toBeNull(); } diff --git a/e2e/waterloo-university.e2e.ts b/e2e/waterloo-university.e2e.ts index 712c16e0..721ccd5c 100644 --- a/e2e/waterloo-university.e2e.ts +++ b/e2e/waterloo-university.e2e.ts @@ -1,6 +1,6 @@ import { expect, test } from "@playwright/test"; import { readFileSync } from "node:fs"; -import { watchForAppFailures } from "./helpers"; +import { editionUrl, watchForAppFailures } from "./helpers"; const questFixture = readFileSync( new URL("../tests/fixtures/waterloo-quest-sanitized.tsv", import.meta.url), @@ -15,7 +15,7 @@ test("Waterloo Quest import preserves the canonical campus through timetable and if (!baseURL) throw new Error("Playwright baseURL is required"); const failures = watchForAppFailures(page, baseURL); - await page.goto("/?university=waterloo&campus=waterloo-main"); + await page.goto(editionUrl(baseURL, "waterloo")); await expect(page.getByText("For University of Waterloo", { exact: true })).toBeVisible(); await expect(page.locator(".brand-scope-pill").first()).toHaveText("Waterloo"); @@ -55,7 +55,7 @@ test("Waterloo Quest import preserves the canonical campus through timetable and await expect(page.locator(".brand-scope-pill").first()).toHaveText("Waterloo"); await expect(page.getByRole("searchbox", { name: "Search Waterloo buildings" })).toBeVisible(); - await page.goto("/route?university=waterloo&campus=waterloo-main"); + await page.goto(editionUrl(baseURL, "waterloo", "/route")); await expect(page.locator(".brand-scope-pill").first()).toHaveText("Waterloo"); await expect(page.getByRole("searchbox", { name: "Search Waterloo buildings" })).toBeVisible(); failures.assertClean(); @@ -69,7 +69,7 @@ test("Waterloo has its own demo timetable and usable mobile campus map", async ( if (!baseURL) throw new Error("Playwright baseURL is required"); const failures = watchForAppFailures(page, baseURL); - await page.goto("/?university=waterloo&campus=waterloo-main"); + await page.goto(editionUrl(baseURL, "waterloo")); await expect(page.getByText("For University of Waterloo", { exact: true })).toBeVisible(); await page.getByRole("button", { name: "Try a demo" }).click(); await expect(page).toHaveURL(/\/timetable/); diff --git a/package.json b/package.json index 6338e48e..3c4a5456 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "@tailwindcss/vite": "^4.2.1", "@tanstack/react-query": "^5.103.2", "@tanstack/react-router": "^1.170.39", - "@tanstack/react-start": "^1.168.58", + "@tanstack/react-start": "1.168.60", "@tanstack/router-plugin": "^1.168.40", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/playwright.config.ts b/playwright.config.ts index 44ab31b7..46833c45 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,7 +1,8 @@ import { defineConfig, devices } from "@playwright/test"; const port = 4173; -const localBaseUrl = `http://127.0.0.1:${port}`; +const localServerUrl = `http://localhost:${port}`; +const localBaseUrl = `http://utm.localhost:${port}`; const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? localBaseUrl; const useExternalServer = Boolean(process.env.PLAYWRIGHT_BASE_URL); @@ -49,7 +50,7 @@ export default defineConfig({ ? undefined : { command: `bun run build && bunx vite preview --host 127.0.0.1 --port ${port}`, - url: localBaseUrl, + url: localServerUrl, reuseExistingServer: !process.env.CI, timeout: 120_000, stdout: "pipe", diff --git a/public/campuses/utm/og-card.png b/public/campuses/utm/og-card.png new file mode 100644 index 00000000..cf1039c6 Binary files /dev/null and b/public/campuses/utm/og-card.png differ diff --git a/public/campuses/utsc/og-card.png b/public/campuses/utsc/og-card.png new file mode 100644 index 00000000..7073e65f Binary files /dev/null and b/public/campuses/utsc/og-card.png differ diff --git a/public/campuses/utsg/og-card.png b/public/campuses/utsg/og-card.png new file mode 100644 index 00000000..47739cb1 Binary files /dev/null and b/public/campuses/utsg/og-card.png differ diff --git a/public/og-card.png b/public/og-card.png index b6ea8f52..061953cc 100644 Binary files a/public/og-card.png and b/public/og-card.png differ diff --git a/public/og-gapwise.png b/public/og-gapwise.png index b6ea8f52..061953cc 100644 Binary files a/public/og-gapwise.png and b/public/og-gapwise.png differ diff --git a/public/sitemap.xml b/public/sitemap.xml index ad9a8e75..fc709e27 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -4,15 +4,9 @@ https://gapwise.ca/about https://gapwise.ca/universities https://gapwise.ca/open-source - https://gapwise.ca/utm-timetable https://gapwise.ca/campus-map https://gapwise.ca/gap-planner https://gapwise.ca/campus-routing - https://gapwise.ca/acorn-import - https://gapwise.ca/places - https://gapwise.ca/places/davis-food-court - https://gapwise.ca/places/utm-library - https://gapwise.ca/places/rawc https://gapwise.ca/developers https://gapwise.ca/ai https://gapwise.ca/support diff --git a/scripts/build-seo-pages.ts b/scripts/build-seo-pages.ts index 200b031d..f6f18193 100644 --- a/scripts/build-seo-pages.ts +++ b/scripts/build-seo-pages.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { PUBLIC_FEATURE_PAGES, editionFeatureMetadata } from "../src/content/public-feature-pages"; @@ -36,7 +36,7 @@ export const UOFT_CONTEXT: UniversityContext = { id: "uoft", name: "University of Toronto", shortName: "U of T", - origin: SITE_ORIGIN, + origin: "https://uoft.gapwise.ca", routable: true, }; @@ -74,8 +74,11 @@ const UOFT_CAMPUS_EDITIONS = [ ] as const satisfies ReadonlyArray<{ campusId: string; context: UniversityContext }>; function socialImageUrl(uniContext?: UniversityContext) { - const origin = uniContext?.origin ?? SITE_ORIGIN; - const id = uniContext?.id ?? "uoft"; + if (!uniContext) return `${SITE_ORIGIN}/og-gapwise.png`; + const origin = uniContext.origin; + if (uniContext.campusName) + return `${origin}/campuses/${uniContext.shortName.toLowerCase()}/og-card.png`; + const id = uniContext.id; return `${origin}/universities/${id}/og-card.png`; } @@ -270,9 +273,7 @@ function homepageStructuredData(page: SeoPage, uniContext?: UniversityContext) { const websiteId = `${origin}/#website`; const appId = `${origin}/#app`; const founderId = `${SITE_ORIGIN}/#andrew-muratov`; - const isDedicatedTenant = Boolean( - uniContext && (uniContext.id !== "uoft" || uniContext.campusName), - ); + const isDedicatedTenant = Boolean(uniContext); const uniName = isDedicatedTenant ? uniContext!.name : null; return { "@context": "https://schema.org", @@ -365,19 +366,17 @@ function homepageStructuredData(page: SeoPage, uniContext?: UniversityContext) { } function metadata(page: SeoPage, uniContext?: UniversityContext) { - const effectiveUni = uniContext ?? UOFT_CONTEXT; - const origin = effectiveUni.origin; + const origin = uniContext?.origin ?? SITE_ORIGIN; const canonical = canonicalUrl(page.path, origin); const title = escapeHtml(page.title); const description = escapeHtml(page.description); - const socialImage = socialImageUrl(effectiveUni); - const socialImageAlt = - uniContext?.id === "uoft" && !uniContext.campusName && page.path === "/" - ? "Gapwise — University timetable and campus navigation" - : `Gapwise — ${escapeHtml(effectiveUni.name)}`; + const socialImage = socialImageUrl(uniContext); + const socialImageAlt = `Gapwise — ${escapeHtml( + uniContext?.name ?? "University timetable and campus navigation", + )}`; const schema = page.path === "/" - ? `\n ` + ? `\n ` : ""; return ` @@ -456,9 +455,7 @@ function fallback(page: SeoPage, uniContext?: UniversityContext) { ) .join("\n"); - const isDedicatedTenant = Boolean( - uniContext && (uniContext.id !== "uoft" || uniContext.campusName), - ); + const isDedicatedTenant = Boolean(uniContext); const disclaimer = isDedicatedTenant ? `Gapwise is an independent student project for students at ${escapeHtml(uniContext!.name)}. It is not an official service of ${escapeHtml(uniContext!.name)} and does not claim university approval, sponsorship, or endorsement.` : `Gapwise is an independent student project for students across supported Canadian universities. It is not an official service of any university and does not claim university approval, sponsorship, or endorsement.`; @@ -477,6 +474,16 @@ function fallback(page: SeoPage, uniContext?: UniversityContext) { ` : ""; + const uoftCampusesSection = + page.path === "/" && uniContext?.id === "uoft" && !uniContext.campusName + ? `
+

Choose your U of T campus

+ +
` + : ""; + return `

Gapwise — Timetable & Campus Navigation

${escapeHtml(page.heading)}

@@ -484,6 +491,7 @@ function fallback(page: SeoPage, uniContext?: UniversityContext) {

${escapeHtml(page.detail)}

${page.path === "/" ? '

Gapwise was created by Andrew Muratov. Gapwise is open source on GitHub.

' : ""} ${universitiesSection} + ${uoftCampusesSection} ${sections}

${disclaimer}

@@ -545,6 +553,9 @@ const UNIVERSITY_COMMON_PATHS = new Set([ "/accessibility", ]); +/** Institution-neutral pages published by the global ecosystem site. */ +const GLOBAL_PUBLIC_PATHS = UNIVERSITY_COMMON_PATHS; + function getUniversityPage(page: SeoPage, uni: UniversityContext): SeoPage { if (page.path === "/") { return { @@ -614,17 +625,10 @@ const baseHtml = await readFile(distIndexPath, "utf8"); for (const page of PAGES) { const destination = join("dist", outputPath(page.path)); await mkdir(dirname(destination), { recursive: true }); - await writeFile( - destination, - renderDocument( - baseHtml, - page.path === "/" ? page : getUniversityPage(page, UOFT_CONTEXT), - UOFT_CONTEXT, - ), - ); + await writeFile(destination, renderDocument(baseHtml, page)); } -const expectedSitemap = renderSitemap(); +const expectedSitemap = renderSitemap(SITE_ORIGIN, GLOBAL_PUBLIC_PATHS); const committedSitemap = await readFile(join("public", "sitemap.xml"), "utf8"); if (committedSitemap !== expectedSitemap) { throw new Error("public/sitemap.xml is out of sync with the production SEO page inventory."); @@ -637,13 +641,23 @@ await writeFile( // Write U of T tenant entry point and sitemap/robots const uoftTenantDir = join("dist", "_universities", "uoft"); +const uoftTenantSeoDir = join(uoftTenantDir, "_seo"); await mkdir(uoftTenantDir, { recursive: true }); -const uoftHomeHtml = renderDocument(baseHtml, PAGES[0]!, UOFT_CONTEXT); -await writeFile(join(uoftTenantDir, "index.html"), uoftHomeHtml); -await writeFile(join(uoftTenantDir, "sitemap.xml"), expectedSitemap); +await mkdir(uoftTenantSeoDir, { recursive: true }); +for (const page of PAGES.filter((candidate) => UNIVERSITY_COMMON_PATHS.has(candidate.path))) { + const html = renderDocument(baseHtml, getUniversityPage(page, UOFT_CONTEXT), UOFT_CONTEXT); + if (page.path === "/") await writeFile(join(uoftTenantDir, "index.html"), html); + else + await writeFile( + join(uoftTenantSeoDir, `${page.path.slice(1).replaceAll("/", "--")}.html`), + html, + ); +} +const uoftSitemap = renderSitemap(UOFT_CONTEXT.origin, UNIVERSITY_COMMON_PATHS); +await writeFile(join(uoftTenantDir, "sitemap.xml"), uoftSitemap); await writeFile( join(uoftTenantDir, "robots.txt"), - await readFile(join("public", "robots.txt"), "utf8"), + renderRobotsTxt(`${UOFT_CONTEXT.origin}/sitemap.xml`), ); for (const { campusId, context } of UOFT_CAMPUS_EDITIONS) { @@ -709,16 +723,25 @@ for (const uni of universitiesManifest.universities) { universityCount++; } -// Remove root files so they cannot shadow host-specific rewrites on Vercel +// Preserve the global cards under a private output path before removing the public +// copies, so host-specific rewrites cannot be shadowed by Vercel's static-file lookup. +const globalAssetDir = join("dist", "_global"); +await mkdir(globalAssetDir, { recursive: true }); +await copyFile(join("dist", "og-gapwise.png"), join(globalAssetDir, "og-gapwise.png")); +await copyFile(join("dist", "og-card.png"), join(globalAssetDir, "og-card.png")); + +// Remove root files so they cannot shadow host-specific rewrites on Vercel. await rm(join("dist", "index.html"), { force: true }); await rm(join("dist", "sitemap.xml"), { force: true }); await rm(join("dist", "robots.txt"), { force: true }); await rm(join("dist", "og-gapwise.png"), { force: true }); await rm(join("dist", "og-card.png"), { force: true }); -const uoftPageCount = PAGES.filter((p) => p.sitemap).length; +const globalPageCount = PAGES.filter( + (page) => page.sitemap && GLOBAL_PUBLIC_PATHS.has(page.path), +).length; console.log( - `Generated ${PAGES.length} crawlable Gapwise HTML entry points and ${uoftPageCount} U of T sitemap URLs.`, + `Generated ${PAGES.length} Gapwise HTML entry points and ${globalPageCount} global sitemap URLs.`, ); console.log( `Generated per-university sitemap + robots.txt for ${universityCount} university editions: ${Object.entries( diff --git a/scripts/check-seo-output.ts b/scripts/check-seo-output.ts index 7a1db4ee..8845454b 100644 --- a/scripts/check-seo-output.ts +++ b/scripts/check-seo-output.ts @@ -80,11 +80,13 @@ for (const rootFile of [ } const [home, sitemap, robots] = await Promise.all([ - readFile("dist/_universities/uoft/index.html", "utf8"), + readFile("dist/_seo/index.html", "utf8"), readFile("dist/_seo/sitemap.xml", "utf8"), readFile("dist/_seo/robots.txt", "utf8"), ]); +await Promise.all([readFile("dist/_global/og-gapwise.png"), readFile("dist/_global/og-card.png")]); + for (const needle of [ 'https://gapwise.ca${path}`, "sitemap"); } +for (const institutionSpecificPath of ["/utm-timetable", "/acorn-import", "/places"]) { + rejectText(sitemap, `https://gapwise.ca${institutionSpecificPath}`, "global sitemap"); +} + for (const privatePath of ["/today", "/timetable", "/gaps", "/oauth/"]) { if (sitemap.includes(`https://gapwise.ca${privatePath}`)) { throw new Error(`private/stateful path leaked into sitemap: ${privatePath}`); @@ -486,10 +494,7 @@ for (const uniId of ALL_UNIVERSITY_IDS) { .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); - const expectedAlt = - uniId === "uoft" - ? "Gapwise — University timetable and campus navigation" - : `Gapwise — ${escapedName}`; + const expectedAlt = `Gapwise — ${escapedName}`; requireText(html, ``, `${uniId} og:image`); requireText( @@ -512,10 +517,7 @@ for (const uniId of ALL_UNIVERSITY_IDS) { ``, `${uniId} twitter:image:alt`, ); - const expectedTitle = - uniId === "uoft" - ? "Gapwise — University Timetable & Campus Navigation" - : `Gapwise for ${escapedName} — Timetable & Campus Navigation`; + const expectedTitle = `Gapwise for ${escapedName} — Timetable & Campus Navigation`; requireText(html, ``, `${uniId} og:title`); requireText(html, `${expectedTitle}`, `${uniId} title`); diff --git a/scripts/generate-social-cards.ts b/scripts/generate-social-cards.ts index 076f11e2..dd76a937 100644 --- a/scripts/generate-social-cards.ts +++ b/scripts/generate-social-cards.ts @@ -62,6 +62,30 @@ const CARDS: CardConfig[] = [ tagline: "Make the time between classes count.", detail: "University of Toronto", }, + { + outputPath: "public/campuses/utm/og-card.png", + accentColor: "#4EA7FE", + textColor: "#93c5fd", + title: "Gapwise · UTM", + tagline: "Timetable & Campus Navigation", + detail: "University of Toronto Mississauga", + }, + { + outputPath: "public/campuses/utsg/og-card.png", + accentColor: "#1E4B87", + textColor: "#93c5fd", + title: "Gapwise · UTSG", + tagline: "Timetable & Campus Navigation", + detail: "University of Toronto St. George", + }, + { + outputPath: "public/campuses/utsc/og-card.png", + accentColor: "#18A999", + textColor: "#5eead4", + title: "Gapwise · UTSC", + tagline: "Timetable & Campus Navigation", + detail: "University of Toronto Scarborough", + }, { outputPath: "public/universities/carleton/og-card.png", accentColor: "#E31B23", @@ -166,14 +190,14 @@ const CARDS: CardConfig[] = [ tagline: "Timetable and downtown campus navigation.", detail: "McGill University · Downtown Campus", }, - // ── Root fallbacks (U of T default) ────────────────────── + // ── Global ecosystem root ──────────────────────────────── { outputPath: "public/og-card.png", accentColor: "#4EA7FE", textColor: "#93c5fd", title: "Gapwise", tagline: "Make the time between classes count.", - detail: "University of Toronto", + detail: "University timetable · Campus navigation · Open source", }, { outputPath: "public/og-gapwise.png", @@ -181,7 +205,7 @@ const CARDS: CardConfig[] = [ textColor: "#93c5fd", title: "Gapwise", tagline: "Make the time between classes count.", - detail: "University of Toronto", + detail: "University timetable · Campus navigation · Open source", }, // ── Supporting Sites ────────────────────────────────────── { diff --git a/src/components/GlobalMarketingHome.tsx b/src/components/GlobalMarketingHome.tsx new file mode 100644 index 00000000..1aafa036 --- /dev/null +++ b/src/components/GlobalMarketingHome.tsx @@ -0,0 +1,501 @@ +import { Link } from "@tanstack/react-router"; +import { + Activity, + ArrowRight, + BookOpen, + Braces, + Check, + ChevronRight, + Code2, + GitBranch, + MapPin, + Navigation, + Search, + Sparkles, +} from "lucide-react"; +import { useMemo, useState, type CSSProperties } from "react"; +import { canonicalUrlForUniversity, supportedUniversities } from "@/universities/registry"; +import "./global-marketing-home.css"; + +const FEATURED_UNIVERSITY_IDS = ["uoft", "waterloo", "mcgill", "ubc", "mcmaster", "queens"]; + +const ecosystemProducts = [ + { + id: "ai", + title: "Gapwise AI", + label: "AI", + copy: "Permissioned campus intelligence and Model Context Protocol tools for assistants.", + href: "https://ai.gapwise.ca", + cta: "Open Gapwise AI", + accent: "#a78bfa", + icon: Sparkles, + internal: false, + }, + { + id: "docs", + title: "Gapwise Docs", + label: "Docs", + copy: "Guides, API references, SDK documentation, and integration details.", + href: "https://docs.gapwise.ca", + cta: "Open docs", + accent: "#38bdf8", + icon: BookOpen, + internal: false, + }, + { + id: "data", + title: "Gapwise Data", + label: "Data", + copy: "Open campus datasets, schemas, and source-backed navigation data.", + href: "https://data.gapwise.ca", + cta: "Explore data", + accent: "#ff5a66", + icon: Braces, + internal: false, + }, + { + id: "status", + title: "Gapwise Status", + label: "Status", + copy: "Public service health, uptime probes, and operational incident history.", + href: "https://status.gapwise.ca", + cta: "Open status", + accent: "#39cf97", + icon: Activity, + internal: false, + }, + { + id: "developers", + title: "Gapwise Developers", + label: "Developers", + copy: "Public API, SDKs, CLI tooling, and contracts for building on Gapwise.", + href: "/developers", + cta: "Developer platform", + accent: "#4ea7fe", + icon: Code2, + internal: true, + }, +] as const; + +function BrandMark({ className = "" }: { className?: string }) { + return ; +} + +function ProductPreview() { + return ( +
+
+ + Example student day + Campus +
+ +
+ + +
+
+
+ Today + Your afternoon +
+ 3 classes · 2 gaps +
+ +
+
+