├── .env.example ├── .eslintrc.json ├── .gitignore ├── .npmrc ├── .nvmrc ├── .prettierrc.json ├── .vscode ├── settings.json └── tasks.json ├── README.md ├── middleware.js ├── next.config.js ├── package-lock.json ├── package.json ├── public ├── assets │ └── icons │ │ ├── icon-128x128.png │ │ ├── icon-144x144.png │ │ ├── icon-152x152.png │ │ ├── icon-192x192.png │ │ ├── icon-384x384.png │ │ ├── icon-48x48.png │ │ ├── icon-512x512.png │ │ ├── icon-72x72.png │ │ └── icon-96x96.png ├── favicon.ico ├── freiburg.png ├── manifest.json └── sunrises-wordmark.svg ├── sentry.client.config.ts ├── sentry.edge.config.ts ├── sentry.server.config.ts ├── src ├── app │ ├── api │ │ ├── dashboard │ │ │ └── route.ts │ │ ├── revalidate │ │ │ └── route.ts │ │ ├── stations │ │ │ └── [station] │ │ │ │ └── route.ts │ │ ├── statuses │ │ │ └── current │ │ │ │ └── route.ts │ │ └── trips │ │ │ └── route.ts │ ├── dashboard │ │ ├── layout.tsx │ │ └── page.tsx │ ├── datenschutz │ │ └── page.tsx │ ├── global-error.jsx │ ├── impressum │ │ └── page.tsx │ ├── layout.tsx │ ├── loading.tsx │ ├── login │ │ └── page.tsx │ ├── not-found.tsx │ ├── offline │ │ └── page.tsx │ ├── page.tsx │ ├── status │ │ ├── [id] │ │ │ ├── page.tsx │ │ │ └── types.ts │ │ └── page.tsx │ ├── traewelling │ │ ├── dashboard │ │ │ ├── future │ │ │ │ └── route.ts │ │ │ ├── global │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── me │ │ │ └── route.ts │ │ ├── notifications │ │ │ └── unread │ │ │ │ └── route.ts │ │ ├── stations │ │ │ ├── [station] │ │ │ │ └── route.ts │ │ │ ├── autocomplete │ │ │ │ └── route.ts │ │ │ ├── checkin │ │ │ │ └── route.ts │ │ │ ├── history │ │ │ │ └── route.ts │ │ │ └── nearby │ │ │ │ └── route.ts │ │ ├── statuses │ │ │ ├── [status] │ │ │ │ ├── like │ │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ │ ├── current │ │ │ │ └── route.ts │ │ │ ├── dashboard │ │ │ │ └── me │ │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── trips │ │ │ └── route.ts │ │ └── user │ │ │ └── [username] │ │ │ └── statuses │ │ │ └── route.ts │ └── u │ │ ├── [username] │ │ ├── layout.tsx │ │ └── page.tsx │ │ └── page.tsx ├── components │ ├── AuthGuard │ │ └── AuthGuard.tsx │ ├── Button │ │ ├── Button.module.scss │ │ ├── Button.tsx │ │ └── types.ts │ ├── CheckIn │ │ ├── CheckIn.context.ts │ │ ├── CheckIn.module.scss │ │ ├── CheckIn.tsx │ │ ├── CurrentStatus │ │ │ ├── CurrentStatus.module.scss │ │ │ └── CurrentStatus.tsx │ │ ├── DestinationStep │ │ │ ├── DestinationStep.module.scss │ │ │ ├── DestinationStep.tsx │ │ │ └── types.ts │ │ ├── FinalStep │ │ │ ├── FinalStep.module.scss │ │ │ └── FinalStep.tsx │ │ ├── NewCurrentStatus │ │ │ ├── NewCurrentStatus.module.scss │ │ │ └── NewCurrentStatus.tsx │ │ ├── OriginStep │ │ │ ├── OriginStep.module.scss │ │ │ ├── OriginStep.tsx │ │ │ └── types.ts │ │ ├── Panel │ │ │ ├── Panel.module.scss │ │ │ ├── Panel.tsx │ │ │ └── types.ts │ │ ├── Search │ │ │ ├── Search.module.scss │ │ │ └── Search.tsx │ │ ├── TripStep │ │ │ ├── TripStep.module.scss │ │ │ └── TripStep.tsx │ │ ├── consts.ts │ │ └── types.ts │ ├── FilterButton │ │ ├── FilterButton.module.scss │ │ ├── FilterButton.tsx │ │ └── types.ts │ ├── FullscreenLoading │ │ ├── FullscreenLoading.module.scss │ │ └── FullscreenLoading.tsx │ ├── IconSkew │ │ ├── IconSkew.module.scss │ │ ├── IconSkew.tsx │ │ └── types.ts │ ├── Layout │ │ ├── Layout.module.scss │ │ ├── Layout.tsx │ │ └── iphone.png │ ├── LegacyTime │ │ ├── LegacyTime.tsx │ │ └── types.ts │ ├── LineIndicator │ │ ├── LineIndicator.module.scss │ │ ├── LineIndicator.tsx │ │ └── types.ts │ ├── LockBodyScroll │ │ └── LockBodyScroll.tsx │ ├── Login │ │ ├── Login.module.scss │ │ └── Login.tsx │ ├── NativeSelect │ │ ├── NativeSelect.module.scss │ │ ├── NativeSelect.tsx │ │ └── types.ts │ ├── Navbar │ │ ├── Navbar.module.scss │ │ ├── Navbar.tsx │ │ └── Username.tsx │ ├── NewLineIndicator │ │ ├── NewLineIndicator.module.scss │ │ ├── NewLineIndicator.tsx │ │ └── types.ts │ ├── Notifications │ │ ├── Notifications.module.scss │ │ └── Notifications.tsx │ ├── Overlay │ │ ├── Overlay.module.scss │ │ ├── Overlay.tsx │ │ └── types.ts │ ├── ProductIcon │ │ ├── ProductIcon.tsx │ │ └── types.ts │ ├── Profile │ │ └── Statuses │ │ │ └── Statuses.tsx │ ├── ProfileDrawer │ │ ├── ProfileDrawer.module.scss │ │ ├── ProfileDrawer.tsx │ │ └── types.ts │ ├── ProfileImage │ │ ├── ProfileImage.module.scss │ │ └── ProfileImage.tsx │ ├── Providers │ │ ├── Providers.tsx │ │ └── types.ts │ ├── Route │ │ ├── Route.module.scss │ │ ├── Route.tsx │ │ └── types.ts │ ├── ScrollArea │ │ ├── ScrollArea.module.scss │ │ ├── ScrollArea.tsx │ │ └── types.ts │ ├── Shimmer │ │ ├── Shimmer.module.scss │ │ ├── Shimmer.tsx │ │ └── types.ts │ ├── StatusCard │ │ ├── StatusCard.module.scss │ │ ├── StatusCard.tsx │ │ └── types.ts │ ├── StatusDetails │ │ ├── StatusDetails.module.scss │ │ ├── StatusDetails.tsx │ │ └── types.ts │ ├── Statuses │ │ ├── Statuses.module.scss │ │ └── Statuses.tsx │ ├── StopoverSelector │ │ ├── StopoverSelector.module.scss │ │ ├── StopoverSelector.tsx │ │ └── types.ts │ ├── ThemeProvider │ │ ├── ThemeProvider.module.scss │ │ ├── ThemeProvider.tsx │ │ └── types.ts │ ├── Time │ │ ├── Time.module.scss │ │ ├── Time.tsx │ │ └── types.ts │ └── TripSelector │ │ ├── TripSelector.module.scss │ │ ├── TripSelector.tsx │ │ └── types.ts ├── contexts │ └── CheckIn │ │ ├── CheckIn.context.tsx │ │ ├── reducer.ts │ │ └── types.ts ├── helpers │ ├── getContrastColor.ts │ ├── getLineTheme │ │ ├── consts.ts │ │ └── getLineTheme.ts │ ├── getStopsAfter.ts │ ├── identifyLineByMagic │ │ ├── conts.ts │ │ └── index.ts │ └── lineAppearance │ │ ├── consts.ts │ │ ├── fetcher.ts │ │ └── index.ts ├── hooks │ ├── useAppTheme │ │ └── useAppTheme.ts │ ├── useCheckIn │ │ └── useCheckIn.ts │ ├── useConsecutiveOverlays │ │ ├── types.ts │ │ └── useConsecutiveOverlays.ts │ ├── useCurrentStatus │ │ └── useCurrentStatus.ts │ ├── useDashboard │ │ └── useDashboard.ts │ ├── useDepartures │ │ ├── types.ts │ │ └── useDepartures.ts │ ├── useIsDesktop │ │ └── useIsDesktop.ts │ ├── useJoinCheckIn │ │ └── useJoinCheckIn.tsx │ ├── useLockBodyScroll │ │ └── useLockBodyScroll.ts │ ├── useNotifications │ │ └── useNotifications.ts │ ├── useOverlayScroll │ │ ├── types.ts │ │ └── useOverlayScroll.ts │ ├── useRecentStations │ │ └── useRecentStations.ts │ ├── useStationSearch │ │ └── useStationSearch.ts │ ├── useStatus │ │ └── useStatus.ts │ ├── useStops │ │ └── useStops.ts │ ├── useTrip │ │ └── useTrip.ts │ ├── useUmami │ │ ├── types.ts │ │ └── useUmami.ts │ └── useUserStatuses │ │ └── useUserStatus.ts ├── overlays │ ├── CompleteCheckIn │ │ ├── CompleteCheckIn.module.scss │ │ ├── CompleteCheckIn.overlay.tsx │ │ └── types.ts │ └── SelectDestination │ │ ├── SelectDestination.module.scss │ │ ├── SelectDestination.overlay.tsx │ │ └── types.ts ├── page-templates │ ├── dashboard.module.scss │ └── dashboard.tsx ├── pages │ ├── _error.jsx │ └── api │ │ └── auth │ │ └── [...nextauth].ts ├── scripts │ └── UmamiScript │ │ └── UmamiScript.tsx ├── styles │ ├── fonts.ts │ └── globals.css ├── traewelling-sdk │ ├── functions │ │ ├── auth.ts │ │ ├── dashboard.ts │ │ ├── notifications.ts │ │ ├── station.ts │ │ ├── status.ts │ │ ├── trains.ts │ │ └── user.ts │ ├── hafasTypes.ts │ ├── index.ts │ ├── transformers.ts │ └── types.ts ├── types │ ├── aboard.ts │ ├── db-clean-station-name.d.ts │ ├── global.d.ts │ └── next-auth.d.ts └── utils │ ├── api │ ├── createErrorResponse.ts │ ├── createResponse.ts │ └── getSafeUrlParams.ts │ ├── debounce.ts │ ├── formatDate.ts │ ├── formatTime.ts │ ├── parseSchedule.ts │ └── sortByLevenshtein.ts ├── tools └── component-generator │ ├── plopfile.js │ └── templates │ ├── component.hbs │ ├── styles.hbs │ └── types.hbs └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | NEXTAUTH_SECRET= 2 | NEXTAUTH_URL= 3 | 4 | # get it from: https://traewelling.de/settings/applications 5 | # redirect_url: /api/auth/callback/traewelling 6 | TRAEWELLING_CLIENT_ID= 7 | TRAEWELLING_CLIENT_SECRET= 8 | 9 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next", 3 | "rules": { 4 | "eol-last": ["error", "always"], 5 | "eqeqeq": "error", 6 | "indent": ["error", 2], 7 | "quotes": ["error", "single"], 8 | "semi": ["error", "always"] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | 39 | public/sw.js 40 | public/sw.js.map 41 | public/workbox-*.js 42 | public/workbox-*.js.map 43 | public/swe-worker-development.js 44 | public/fallback-development.js 45 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-prefix="" 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "semi": true, 4 | "singleQuote": true, 5 | "trailingComma": "es5" 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll.eslint": "explicit", 4 | "source.organizeImports": "explicit" 5 | }, 6 | "editor.formatOnSave": true, 7 | "typescript.tsdk": "node_modules/typescript/lib", 8 | "typescript.enablePromptUseWorkspaceTsdk": true 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "detail": "npm run dev", 6 | "label": "Next (dev)", 7 | "group": { 8 | "kind": "build", 9 | "isDefault": true, 10 | }, 11 | "icon": { 12 | "color": "terminal.ansiBlue", 13 | "id": "rocket", 14 | }, 15 | "isBackground": true, 16 | "type": "npm", 17 | "script": "dev", 18 | "presentation": { 19 | "clear": true, 20 | "panel": "dedicated", 21 | "reveal": "never", 22 | "showReuseMessage": false, 23 | }, 24 | "problemMatcher": [], 25 | }, 26 | ], 27 | } 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | ``` 14 | 15 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 16 | 17 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. 18 | 19 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. 20 | 21 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 22 | 23 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 24 | 25 | ## Learn More 26 | 27 | To learn more about Next.js, take a look at the following resources: 28 | 29 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 30 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 31 | 32 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 33 | 34 | ## Deploy on Vercel 35 | 36 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 37 | 38 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 39 | -------------------------------------------------------------------------------- /middleware.js: -------------------------------------------------------------------------------- 1 | export { default } from 'next-auth/middleware'; 2 | 3 | export const config = { matcher: ['/dashboard'] }; 4 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const withPWA = require('@ducanh2912/next-pwa').default({ 2 | cacheOnFrontEndNav: true, 3 | aggressiveFrontEndNavCaching: true, 4 | reloadOnOnline: true, 5 | swcMinify: true, 6 | dest: 'public', 7 | fallbacks: { 8 | //image: "/static/images/fallback.png", 9 | document: '/offline', // if you want to fallback to a custom page rather than /_offline 10 | // font: '/static/font/fallback.woff2', 11 | // audio: ..., 12 | // video: ..., 13 | }, 14 | workboxOptions: { 15 | disableDevLogs: true, 16 | }, 17 | // ... other options you like 18 | }); 19 | 20 | /** @type {import('next').NextConfig} */ 21 | const nextConfig = { 22 | reactStrictMode: true, 23 | 24 | images: { 25 | remotePatterns: [ 26 | { 27 | protocol: 'https', 28 | hostname: 'traewelling.de', 29 | }, 30 | ], 31 | }, 32 | redirects: async () => { 33 | return [ 34 | { 35 | source: '/', 36 | destination: '/dashboard', 37 | permanent: false, 38 | }, 39 | ]; 40 | }, 41 | rewrites: async () => { 42 | return [ 43 | { 44 | source: '/tracking/:path*', 45 | destination: 'https://tracking.nbank.dev/:path*', 46 | }, 47 | ]; 48 | }, 49 | }; 50 | 51 | module.exports = withPWA(nextConfig); 52 | 53 | // Injected content via Sentry wizard below 54 | 55 | const { withSentryConfig } = require('@sentry/nextjs'); 56 | 57 | module.exports = withSentryConfig( 58 | module.exports, 59 | { 60 | // For all available options, see: 61 | // https://github.com/getsentry/sentry-webpack-plugin#options 62 | 63 | // Suppresses source map uploading logs during build 64 | silent: true, 65 | org: 'nbankdev', 66 | project: 'aboard', 67 | }, 68 | { 69 | // For all available options, see: 70 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ 71 | 72 | // Upload a larger set of source maps for prettier stack traces (increases build time) 73 | widenClientFileUpload: true, 74 | 75 | // Transpiles SDK to be compatible with IE11 (increases bundle size) 76 | transpileClientSDK: true, 77 | 78 | // Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers. (increases server load) 79 | // Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client- 80 | // side errors will fail. 81 | tunnelRoute: '/monitoring', 82 | 83 | // Hides source maps from generated client bundles 84 | hideSourceMaps: true, 85 | 86 | // Automatically tree-shake Sentry logger statements to reduce bundle size 87 | disableLogger: true, 88 | 89 | // Enables automatic instrumentation of Vercel Cron Monitors. 90 | // See the following for more information: 91 | // https://docs.sentry.io/product/crons/ 92 | // https://vercel.com/docs/cron-jobs 93 | automaticVercelMonitors: true, 94 | } 95 | ); 96 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aboard", 3 | "author": { 4 | "email": "info@sunrises.dev", 5 | "name": "Sunrises", 6 | "url": "https://sunrises.dev" 7 | }, 8 | "maintainers": [ 9 | "Fabian K. ", 10 | "Noel B. " 11 | ], 12 | "version": "0.1.0", 13 | "private": true, 14 | "scripts": { 15 | "dev": "next dev", 16 | "build": "next build", 17 | "start": "next start", 18 | "lint": "next lint", 19 | "cc": "plop component --plopfile tools/component-generator/plopfile.js" 20 | }, 21 | "dependencies": { 22 | "@ducanh2912/next-pwa": "10.1.0", 23 | "@radix-ui/colors": "3.0.0", 24 | "@radix-ui/react-dialog": "1.0.5", 25 | "@radix-ui/react-radio-group": "1.1.3", 26 | "@radix-ui/react-scroll-area": "1.0.5", 27 | "@radix-ui/react-tabs": "1.0.4", 28 | "@sentry/nextjs": "7.108.0", 29 | "clsx": "2.0.0", 30 | "color-convert": "2.0.1", 31 | "db-clean-station-name": "1.2.0", 32 | "framer-motion": "10.16.4", 33 | "js-levenshtein": "1.1.6", 34 | "next": "14.0.4", 35 | "next-auth": "4.24.5", 36 | "normalize.css": "8.0.1", 37 | "papaparse": "5.4.1", 38 | "react": "18.2.0", 39 | "react-dom": "18.2.0", 40 | "react-hot-toast": "2.4.1", 41 | "react-icons": "4.12.0", 42 | "react-modal-sheet": "2.2.0", 43 | "sass": "1.69.5", 44 | "swr": "2.2.4", 45 | "typescript": "5.2.2" 46 | }, 47 | "devDependencies": { 48 | "@types/color-convert": "2.0.3", 49 | "@types/js-levenshtein": "1.1.3", 50 | "@types/node": "20.9.0", 51 | "@types/papaparse": "5.3.14", 52 | "@types/react": "18.2.37", 53 | "@types/react-dom": "18.2.15", 54 | "@types/umami": "0.1.5", 55 | "@typescript-eslint/eslint-plugin": "^6.10.0", 56 | "@typescript-eslint/parser": "^6.10.0", 57 | "eslint": "8.53.0", 58 | "eslint-config-next": "14.0.1", 59 | "plop": "4.0.0", 60 | "prettier": "3.0.3" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /public/assets/icons/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunrisesdev/aboard/59a13a30ad57c37c8283fbc873a43edcff48316d/public/assets/icons/icon-128x128.png -------------------------------------------------------------------------------- /public/assets/icons/icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunrisesdev/aboard/59a13a30ad57c37c8283fbc873a43edcff48316d/public/assets/icons/icon-144x144.png -------------------------------------------------------------------------------- /public/assets/icons/icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunrisesdev/aboard/59a13a30ad57c37c8283fbc873a43edcff48316d/public/assets/icons/icon-152x152.png -------------------------------------------------------------------------------- /public/assets/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunrisesdev/aboard/59a13a30ad57c37c8283fbc873a43edcff48316d/public/assets/icons/icon-192x192.png -------------------------------------------------------------------------------- /public/assets/icons/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunrisesdev/aboard/59a13a30ad57c37c8283fbc873a43edcff48316d/public/assets/icons/icon-384x384.png -------------------------------------------------------------------------------- /public/assets/icons/icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunrisesdev/aboard/59a13a30ad57c37c8283fbc873a43edcff48316d/public/assets/icons/icon-48x48.png -------------------------------------------------------------------------------- /public/assets/icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunrisesdev/aboard/59a13a30ad57c37c8283fbc873a43edcff48316d/public/assets/icons/icon-512x512.png -------------------------------------------------------------------------------- /public/assets/icons/icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunrisesdev/aboard/59a13a30ad57c37c8283fbc873a43edcff48316d/public/assets/icons/icon-72x72.png -------------------------------------------------------------------------------- /public/assets/icons/icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunrisesdev/aboard/59a13a30ad57c37c8283fbc873a43edcff48316d/public/assets/icons/icon-96x96.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunrisesdev/aboard/59a13a30ad57c37c8283fbc873a43edcff48316d/public/favicon.ico -------------------------------------------------------------------------------- /public/freiburg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunrisesdev/aboard/59a13a30ad57c37c8283fbc873a43edcff48316d/public/freiburg.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "theme_color": "#ffffff", 3 | "background_color": "#ffffff", 4 | "display": "fullscreen", 5 | "scope": "/", 6 | "start_url": "/", 7 | "name": "aboard.at", 8 | "short_name": "Aboard", 9 | "icons": [ 10 | { 11 | "src": "assets/icons/icon-72x72.png", 12 | "sizes": "72x72", 13 | "type": "image/png", 14 | "purpose": "maskable any" 15 | }, 16 | { 17 | "src": "assets/icons/icon-96x96.png", 18 | "sizes": "96x96", 19 | "type": "image/png", 20 | "purpose": "maskable any" 21 | }, 22 | { 23 | "src": "assets/icons/icon-128x128.png", 24 | "sizes": "128x128", 25 | "type": "image/png", 26 | "purpose": "maskable any" 27 | }, 28 | { 29 | "src": "assets/icons/icon-144x144.png", 30 | "sizes": "144x144", 31 | "type": "image/png", 32 | "purpose": "maskable any" 33 | }, 34 | { 35 | "src": "assets/icons/icon-152x152.png", 36 | "sizes": "152x152", 37 | "type": "image/png", 38 | "purpose": "maskable any" 39 | }, 40 | { 41 | "src": "assets/icons/icon-192x192.png", 42 | "sizes": "192x192", 43 | "type": "image/png", 44 | "purpose": "maskable any" 45 | }, 46 | { 47 | "src": "assets/icons/icon-384x384.png", 48 | "sizes": "384x384", 49 | "type": "image/png", 50 | "purpose": "maskable any" 51 | }, 52 | { 53 | "src": "assets/icons/icon-512x512.png", 54 | "sizes": "512x512", 55 | "type": "image/png", 56 | "purpose": "maskable any" 57 | } 58 | ], 59 | "orientation": "portrait", 60 | "prefer_related_applications": true 61 | } 62 | -------------------------------------------------------------------------------- /sentry.client.config.ts: -------------------------------------------------------------------------------- 1 | // This file configures the initialization of Sentry on the client. 2 | // The config you add here will be used whenever a users loads a page in their browser. 3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/ 4 | 5 | import * as Sentry from '@sentry/nextjs'; 6 | 7 | Sentry.init({ 8 | dsn: 'https://0312bc7c729538ea57cf0b90ddedeb5b@o4506972782395392.ingest.us.sentry.io/4506989095550976', 9 | 10 | // Adjust this value in production, or use tracesSampler for greater control 11 | tracesSampleRate: 1, 12 | 13 | // Setting this option to true will print useful information to the console while you're setting up Sentry. 14 | debug: false, 15 | 16 | replaysOnErrorSampleRate: 1.0, 17 | 18 | // This sets the sample rate to be 10%. You may want this to be 100% while 19 | // in development and sample at a lower rate in production 20 | replaysSessionSampleRate: 0.1, 21 | 22 | // You can remove this option if you're not planning to use the Sentry Session Replay feature: 23 | integrations: [ 24 | Sentry.replayIntegration({ 25 | // Additional Replay configuration goes in here, for example: 26 | maskAllText: true, 27 | blockAllMedia: true, 28 | }), 29 | ], 30 | }); 31 | -------------------------------------------------------------------------------- /sentry.edge.config.ts: -------------------------------------------------------------------------------- 1 | // This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on). 2 | // The config you add here will be used whenever one of the edge features is loaded. 3 | // Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally. 4 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/ 5 | 6 | import * as Sentry from '@sentry/nextjs'; 7 | 8 | Sentry.init({ 9 | dsn: 'https://0312bc7c729538ea57cf0b90ddedeb5b@o4506972782395392.ingest.us.sentry.io/4506989095550976', 10 | 11 | // Adjust this value in production, or use tracesSampler for greater control 12 | tracesSampleRate: 1, 13 | 14 | // Setting this option to true will print useful information to the console while you're setting up Sentry. 15 | debug: false, 16 | }); 17 | -------------------------------------------------------------------------------- /sentry.server.config.ts: -------------------------------------------------------------------------------- 1 | // This file configures the initialization of Sentry on the server. 2 | // The config you add here will be used whenever the server handles a request. 3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/ 4 | 5 | import * as Sentry from '@sentry/nextjs'; 6 | 7 | Sentry.init({ 8 | dsn: 'https://0312bc7c729538ea57cf0b90ddedeb5b@o4506972782395392.ingest.us.sentry.io/4506989095550976', 9 | 10 | // Adjust this value in production, or use tracesSampler for greater control 11 | tracesSampleRate: 1, 12 | 13 | // Setting this option to true will print useful information to the console while you're setting up Sentry. 14 | debug: false, 15 | 16 | // uncomment the line below to enable Spotlight (https://spotlightjs.com) 17 | // spotlight: process.env.NODE_ENV === 'development', 18 | }); 19 | -------------------------------------------------------------------------------- /src/app/api/dashboard/route.ts: -------------------------------------------------------------------------------- 1 | import { identifyLineByMagic } from '@/helpers/identifyLineByMagic'; 2 | import { createLineAppearanceDataset } from '@/helpers/lineAppearance'; 3 | import { authOptions } from '@/pages/api/auth/[...nextauth]'; 4 | import { TraewellingSdk } from '@/traewelling-sdk'; 5 | import { transformTrwlStatus } from '@/traewelling-sdk/transformers'; 6 | import { AboardStatus } from '@/types/aboard'; 7 | import createErrorResponse from '@/utils/api/createErrorResponse'; 8 | import createResponse from '@/utils/api/createResponse'; 9 | import { getServerSession } from 'next-auth'; 10 | 11 | export type AboardDashboardResponse = AboardStatus[] | null; 12 | 13 | export async function GET(request: Request) { 14 | try { 15 | const session = await getServerSession(authOptions); 16 | 17 | if (!session) { 18 | return createErrorResponse({ 19 | error: 'No session', 20 | statusCode: 401, 21 | }); 22 | } 23 | 24 | const data = await TraewellingSdk.dashboard.personal(); 25 | 26 | const transformedData: AboardDashboardResponse = 27 | data?.map(transformTrwlStatus) ?? null; 28 | 29 | if (!transformedData) { 30 | return createResponse({ 31 | body: transformedData, 32 | }); 33 | } 34 | 35 | const { getAppearanceForLine } = await createLineAppearanceDataset(); 36 | 37 | transformedData.forEach((status) => { 38 | status.journey.line.appearance = getAppearanceForLine( 39 | identifyLineByMagic(status.journey.hafasTripId, status.journey.line) 40 | ); 41 | }); 42 | 43 | return createResponse({ 44 | body: transformedData, 45 | }); 46 | } catch (error) { 47 | return createErrorResponse({ error }); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/app/api/revalidate/route.ts: -------------------------------------------------------------------------------- 1 | import createResponse from '@/utils/api/createResponse'; 2 | import getSafeURLParams from '@/utils/api/getSafeUrlParams'; 3 | import { revalidateTag } from 'next/cache'; 4 | 5 | export async function GET(request: Request) { 6 | const { tag } = getSafeURLParams({ 7 | url: request.url, 8 | requiredParams: ['tag'], 9 | }); 10 | 11 | if (tag) { 12 | revalidateTag(tag); 13 | } 14 | 15 | return createResponse({ 16 | statusCode: 200, 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /src/app/api/stations/[station]/route.ts: -------------------------------------------------------------------------------- 1 | import { identifyLineByMagic } from '@/helpers/identifyLineByMagic'; 2 | import { createLineAppearanceDataset } from '@/helpers/lineAppearance'; 3 | import { authOptions } from '@/pages/api/auth/[...nextauth]'; 4 | import { TraewellingSdk } from '@/traewelling-sdk'; 5 | import { transformHAFASTrip } from '@/traewelling-sdk/transformers'; 6 | import { TransportType } from '@/traewelling-sdk/types'; 7 | import { AboardStation, AboardTrip } from '@/types/aboard'; 8 | import createErrorResponse from '@/utils/api/createErrorResponse'; 9 | import createResponse from '@/utils/api/createResponse'; 10 | import getSafeURLParams from '@/utils/api/getSafeUrlParams'; 11 | import { getServerSession } from 'next-auth'; 12 | 13 | const ALLOWED_TRANSPORT_TYPES = [ 14 | 'bus', 15 | 'express', 16 | 'ferry', 17 | 'regional', 18 | 'suburban', 19 | 'subway', 20 | 'taxi', 21 | 'tram', 22 | ]; 23 | 24 | export type AboardDeparturesResponse = { 25 | meta: { 26 | station: AboardStation; 27 | times: { 28 | next: string; 29 | now: string; 30 | prev: string; 31 | }; 32 | } | null; 33 | trips: AboardTrip[]; 34 | }; 35 | 36 | export async function GET( 37 | request: Request, 38 | context: { params: { station: string } } 39 | ) { 40 | try { 41 | const session = await getServerSession(authOptions); 42 | 43 | const { transportType, from } = getSafeURLParams({ 44 | url: request.url, 45 | optionalParams: ['transportType', 'from'], 46 | requiredParams: [], 47 | }); 48 | 49 | if (!session) { 50 | return createErrorResponse({ 51 | error: 'No session', 52 | statusCode: 401, 53 | }); 54 | } 55 | 56 | if ( 57 | !(context.params.station ?? '').trim() || 58 | (!!transportType && !ALLOWED_TRANSPORT_TYPES.includes(transportType)) 59 | ) { 60 | return createErrorResponse({ 61 | error: 'Invalid parameters', 62 | statusCode: 400, 63 | }); 64 | } 65 | 66 | const data = await TraewellingSdk.station.departures({ 67 | id: +context.params.station, 68 | travelType: transportType as TransportType, 69 | when: from, 70 | }); 71 | 72 | const { getAppearanceForLine } = await createLineAppearanceDataset(); 73 | 74 | const transformedData: AboardDeparturesResponse = { 75 | meta: data.meta && { 76 | station: { 77 | evaId: data.meta.station.id, 78 | ibnr: data.meta.station.ibnr, 79 | latitude: +data.meta.station.latitude, 80 | longitude: +data.meta.station.longitude, 81 | name: data.meta.station.name, 82 | rilId: data.meta.station.rilIdentifier ?? undefined, 83 | trwlId: undefined, 84 | } as AboardStation, 85 | times: data.meta?.times, 86 | }, 87 | trips: data.trips.map(transformHAFASTrip), 88 | }; 89 | 90 | transformedData.trips.forEach((trip) => { 91 | trip.line.appearance = getAppearanceForLine( 92 | identifyLineByMagic(trip.hafasId, trip.line) 93 | ); 94 | }); 95 | 96 | return createResponse({ 97 | body: transformedData, 98 | }); 99 | } catch (error) { 100 | console.log(error); 101 | 102 | return createErrorResponse({ error }); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/app/api/statuses/current/route.ts: -------------------------------------------------------------------------------- 1 | import { identifyLineByMagic } from '@/helpers/identifyLineByMagic'; 2 | import { createLineAppearanceDataset } from '@/helpers/lineAppearance'; 3 | import { authOptions } from '@/pages/api/auth/[...nextauth]'; 4 | import { TraewellingSdk } from '@/traewelling-sdk'; 5 | import { transformTrwlStatus } from '@/traewelling-sdk/transformers'; 6 | import { AboardStatus } from '@/types/aboard'; 7 | import createErrorResponse from '@/utils/api/createErrorResponse'; 8 | import createResponse from '@/utils/api/createResponse'; 9 | import { getServerSession } from 'next-auth'; 10 | 11 | export type AboardCurrentStatusResponse = AboardStatus | null; 12 | 13 | export async function GET(request: Request) { 14 | try { 15 | const session = await getServerSession(authOptions); 16 | 17 | if (!session) { 18 | return createErrorResponse({ 19 | error: 'No session', 20 | statusCode: 401, 21 | }); 22 | } 23 | 24 | const data = await TraewellingSdk.user.activeStatus(); 25 | 26 | const transformedData: AboardCurrentStatusResponse = !data 27 | ? null 28 | : transformTrwlStatus(data); 29 | 30 | if (!transformedData) { 31 | return createResponse({ 32 | body: transformedData, 33 | }); 34 | } 35 | 36 | const { getAppearanceForLine } = await createLineAppearanceDataset(); 37 | 38 | transformedData.journey.line.appearance = getAppearanceForLine( 39 | identifyLineByMagic( 40 | transformedData.journey.hafasTripId, 41 | transformedData.journey.line 42 | ) 43 | ); 44 | 45 | return createResponse({ 46 | body: transformedData, 47 | }); 48 | } catch (error) { 49 | return createErrorResponse({ error }); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/app/api/trips/route.ts: -------------------------------------------------------------------------------- 1 | import { authOptions } from '@/pages/api/auth/[...nextauth]'; 2 | import { TraewellingSdk } from '@/traewelling-sdk'; 3 | import createErrorResponse from '@/utils/api/createErrorResponse'; 4 | import createResponse from '@/utils/api/createResponse'; 5 | import getSafeURLParams from '@/utils/api/getSafeUrlParams'; 6 | import { getServerSession } from 'next-auth'; 7 | 8 | export async function GET(request: Request) { 9 | try { 10 | const { hafasTripId, lineName, start } = getSafeURLParams({ 11 | url: request.url, 12 | requiredParams: ['hafasTripId', 'lineName', 'start'], 13 | }); 14 | 15 | const session = await getServerSession(authOptions); 16 | 17 | if (!session) { 18 | return createErrorResponse({ 19 | error: 'No session', 20 | statusCode: 401, 21 | }); 22 | } 23 | const data = await TraewellingSdk.trains.trip({ 24 | hafasTripId, 25 | lineName, 26 | start, 27 | }); 28 | 29 | return createResponse({ 30 | body: data, 31 | }); 32 | } catch (error) { 33 | return createErrorResponse({ error }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/app/dashboard/layout.tsx: -------------------------------------------------------------------------------- 1 | import { AuthGuard } from '@/components/AuthGuard/AuthGuard'; 2 | import '@/styles/globals.css'; 3 | import 'normalize.css'; 4 | 5 | export default async function RootLayout({ 6 | children, 7 | }: { 8 | children: React.ReactNode; 9 | }) { 10 | return {children}; 11 | } 12 | 13 | export const metadata = { 14 | title: 'Dashboard - aboard.at', 15 | }; 16 | -------------------------------------------------------------------------------- /src/app/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | import DashboardHome from '@/page-templates/dashboard'; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/datenschutz/page.tsx: -------------------------------------------------------------------------------- 1 | export default function Page() { 2 | return
Coming soon
; 3 | } 4 | 5 | export const metadata = { 6 | title: 'Datenschutz - aboard.at', 7 | }; 8 | -------------------------------------------------------------------------------- /src/app/global-error.jsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as Sentry from '@sentry/nextjs'; 4 | import Error from 'next/error'; 5 | import { useEffect } from 'react'; 6 | 7 | export default function GlobalError({ error }) { 8 | useEffect(() => { 9 | Sentry.captureException(error); 10 | }, [error]); 11 | 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/app/impressum/page.tsx: -------------------------------------------------------------------------------- 1 | export default function Page() { 2 | return ( 3 |
4 |

5 | Mailboxde.com GmbH 6 |
nbank, ID 176434 7 |
Äussere Weberstr. 57 8 |
02763 Zittau, GERMANY 9 |
10 | aboard@sunrises.dev 11 |

12 |
13 | ); 14 | } 15 | 16 | export const metadata = { 17 | title: 'Impressum - aboard.at', 18 | }; 19 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import Layout from '@/components/Layout/Layout'; 2 | import Providers from '@/components/Providers/Providers'; 3 | import UmamiScript from '@/scripts/UmamiScript/UmamiScript'; 4 | import { sourceSans3 } from '@/styles/fonts'; 5 | import '@/styles/globals.css'; 6 | import type { Metadata, Viewport } from 'next'; 7 | import { Session } from 'next-auth'; 8 | import { getServerSession } from 'next-auth/next'; 9 | import 'normalize.css'; 10 | import { authOptions } from '../pages/api/auth/[...nextauth]'; 11 | 12 | export default async function RootLayout({ 13 | children, 14 | }: { 15 | children: React.ReactNode; 16 | }) { 17 | const session = await getServerSession(authOptions); 18 | 19 | return ( 20 | 21 | 22 | 23 | 24 | {children} 25 | 26 | 27 | 28 | 29 | ); 30 | } 31 | 32 | const APP_NAME = 'Aboard'; 33 | const APP_DEFAULT_TITLE = 'aboard.at'; 34 | const APP_TITLE_TEMPLATE = '%s - Aboard.at'; 35 | const APP_DESCRIPTION = 36 | 'Aboard is an alternative webclient for Träwelling focused on mobile UX.'; 37 | 38 | export const metadata: Metadata = { 39 | icons: [ 40 | { 41 | rel: 'icon', 42 | url: '/favicon.ico', 43 | }, 44 | ], 45 | applicationName: APP_NAME, 46 | title: { 47 | default: APP_DEFAULT_TITLE, 48 | template: APP_TITLE_TEMPLATE, 49 | }, 50 | description: APP_DESCRIPTION, 51 | manifest: '/manifest.json', 52 | appleWebApp: { 53 | capable: true, 54 | statusBarStyle: 'black-translucent', 55 | title: APP_DEFAULT_TITLE, 56 | // startUpImage: [], 57 | }, 58 | formatDetection: { 59 | telephone: false, 60 | }, 61 | openGraph: { 62 | type: 'website', 63 | siteName: APP_NAME, 64 | title: { 65 | default: APP_DEFAULT_TITLE, 66 | template: APP_TITLE_TEMPLATE, 67 | }, 68 | description: APP_DESCRIPTION, 69 | }, 70 | twitter: { 71 | card: 'summary', 72 | title: { 73 | default: APP_DEFAULT_TITLE, 74 | template: APP_TITLE_TEMPLATE, 75 | }, 76 | description: APP_DESCRIPTION, 77 | }, 78 | }; 79 | 80 | export const viewport: Viewport = { 81 | initialScale: 1, 82 | themeColor: '#FFFFFF', 83 | width: 'device-width', 84 | viewportFit: 'cover', 85 | }; 86 | -------------------------------------------------------------------------------- /src/app/loading.tsx: -------------------------------------------------------------------------------- 1 | import FullscreenLoading from '@/components/FullscreenLoading/FullscreenLoading'; 2 | 3 | export default function Loading() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/login/page.tsx: -------------------------------------------------------------------------------- 1 | import Login from '@/components/Login/Login'; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/not-found.tsx: -------------------------------------------------------------------------------- 1 | export default function NotFound() { 2 | return ( 3 | <> 4 |

Not Found

5 |

Could not find requested resource

6 | 7 | ); 8 | } 9 | -------------------------------------------------------------------------------- /src/app/offline/page.tsx: -------------------------------------------------------------------------------- 1 | export default function Page() { 2 | return
Entschuldigung, du bist leider Offline!
; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import Login from '@/components/Login/Login'; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/status/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import StatusDetails from '@/components/StatusDetails/StatusDetails'; 2 | import { identifyLineByMagic } from '@/helpers/identifyLineByMagic'; 3 | import { createLineAppearanceDataset } from '@/helpers/lineAppearance'; 4 | import { TraewellingSdk } from '@/traewelling-sdk'; 5 | import { 6 | transformTrwlStatus, 7 | transformTrwlTrip, 8 | } from '@/traewelling-sdk/transformers'; 9 | import { Metadata } from 'next'; 10 | import { notFound } from 'next/navigation'; 11 | import { StatusPageProps } from './types'; 12 | 13 | async function getStatusData(id: string) { 14 | const status = await TraewellingSdk.status.single({ id }); 15 | 16 | if (!status) return null; 17 | 18 | const transformed = transformTrwlStatus(status); 19 | const { getAppearanceForLine } = await createLineAppearanceDataset(); 20 | 21 | transformed.journey.line.appearance = getAppearanceForLine( 22 | identifyLineByMagic( 23 | transformed.journey.hafasTripId, 24 | transformed.journey.line 25 | ) 26 | ); 27 | 28 | return transformed; 29 | } 30 | 31 | async function getTripData( 32 | hafasTripId: string, 33 | lineName: string, 34 | start: string 35 | ) { 36 | try { 37 | const trip = await TraewellingSdk.trains.trip({ 38 | hafasTripId, 39 | lineName, 40 | start, 41 | }); 42 | 43 | if (!('id' in trip)) { 44 | return trip; 45 | } 46 | 47 | return transformTrwlTrip(trip); 48 | } catch (error) { 49 | console.error(error); 50 | return { stopovers: [] }; 51 | } 52 | } 53 | 54 | export async function generateMetadata({ 55 | params, 56 | }: StatusPageProps): Promise { 57 | const status = await getStatusData(params.id); 58 | 59 | return { 60 | title: `${status?.username} reist nach ${status?.journey.destination.station.name} - aboard.at`, 61 | description: `Nutze aboard.at um ${status?.username} auf seiner Reise nach ${status?.journey.destination.station.name} zu begleiten.`, 62 | }; 63 | } 64 | 65 | export default async function Page({ params }: StatusPageProps) { 66 | const status = await getStatusData(params.id); 67 | 68 | if (!status) notFound(); 69 | 70 | const tripData = await getTripData( 71 | status.journey.hafasTripId, 72 | status.journey.line.name, 73 | status.journey.origin.station.trwlId!.toString() 74 | ); 75 | 76 | if ('trwlId' in tripData) { 77 | tripData.hafasId = status.journey.hafasTripId; 78 | tripData.line = status.journey.line; 79 | } 80 | 81 | const arrivingAt = new Date( 82 | status.journey.destination.arrival.planned! 83 | ).toISOString(); 84 | const departuringAt = new Date( 85 | status.journey.origin.departure.planned! 86 | ).toISOString(); 87 | 88 | const destinationIndex = tripData.stopovers?.findLastIndex( 89 | ({ arrival, station }) => 90 | new Date(arrival.planned!).toISOString() === arrivingAt && 91 | station.trwlId === status.journey.destination.station.trwlId 92 | ); 93 | 94 | const originIndex = tripData.stopovers?.findIndex( 95 | ({ departure, station }) => 96 | new Date(departure.planned!).toISOString() === departuringAt && 97 | station.trwlId === status.journey.origin.station.trwlId 98 | ); 99 | 100 | return ( 101 | 108 | ); 109 | } 110 | 111 | export const revalidate = 60; 112 | -------------------------------------------------------------------------------- /src/app/status/[id]/types.ts: -------------------------------------------------------------------------------- 1 | export type StatusPageProps = { 2 | params: { 3 | id: string; 4 | }; 5 | }; 6 | -------------------------------------------------------------------------------- /src/app/status/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation'; 2 | 3 | export default async function Page() { 4 | redirect('/dashboard'); 5 | } 6 | -------------------------------------------------------------------------------- /src/app/traewelling/dashboard/future/route.ts: -------------------------------------------------------------------------------- 1 | import { authOptions } from '@/pages/api/auth/[...nextauth]'; 2 | import { TraewellingSdk } from '@/traewelling-sdk'; 3 | import createErrorResponse from '@/utils/api/createErrorResponse'; 4 | import createResponse from '@/utils/api/createResponse'; 5 | import { getServerSession } from 'next-auth'; 6 | 7 | export async function GET(request: Request) { 8 | try { 9 | const session = await getServerSession(authOptions); 10 | 11 | if (!session) { 12 | return createErrorResponse({ 13 | error: 'No session', 14 | statusCode: 401, 15 | }); 16 | } 17 | 18 | const data = await TraewellingSdk.dashboard.personalFuture(); 19 | return createResponse({ 20 | body: data, 21 | }); 22 | } catch (error) { 23 | return createErrorResponse({ error }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/app/traewelling/dashboard/global/route.ts: -------------------------------------------------------------------------------- 1 | import { TraewellingSdk } from '@/traewelling-sdk'; 2 | import createErrorResponse from '@/utils/api/createErrorResponse'; 3 | import createResponse from '@/utils/api/createResponse'; 4 | 5 | export async function GET(request: Request) { 6 | try { 7 | const data = await TraewellingSdk.dashboard.global(); 8 | return createResponse({ 9 | body: data, 10 | }); 11 | } catch (error) { 12 | return createErrorResponse({ error }); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/app/traewelling/dashboard/route.ts: -------------------------------------------------------------------------------- 1 | import { authOptions } from '@/pages/api/auth/[...nextauth]'; 2 | import { TraewellingSdk } from '@/traewelling-sdk'; 3 | import createErrorResponse from '@/utils/api/createErrorResponse'; 4 | import createResponse from '@/utils/api/createResponse'; 5 | import { getServerSession } from 'next-auth'; 6 | 7 | export async function GET(request: Request) { 8 | try { 9 | const session = await getServerSession(authOptions); 10 | 11 | if (!session) { 12 | return createErrorResponse({ 13 | error: 'No session', 14 | statusCode: 401, 15 | }); 16 | } 17 | 18 | const data = await TraewellingSdk.dashboard.personal(); 19 | return createResponse({ 20 | body: data, 21 | }); 22 | } catch (error) { 23 | return createErrorResponse({ error }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/app/traewelling/me/route.ts: -------------------------------------------------------------------------------- 1 | import { authOptions } from '@/pages/api/auth/[...nextauth]'; 2 | import { TraewellingSdk } from '@/traewelling-sdk'; 3 | import createErrorResponse from '@/utils/api/createErrorResponse'; 4 | import createResponse from '@/utils/api/createResponse'; 5 | import { getServerSession } from 'next-auth'; 6 | 7 | export async function GET(request: Request) { 8 | try { 9 | const session = await getServerSession(authOptions); 10 | 11 | if (!session) { 12 | return createErrorResponse({ 13 | error: 'No session', 14 | statusCode: 401, 15 | }); 16 | } 17 | 18 | const data = await TraewellingSdk.auth.user(); 19 | return createResponse({ 20 | body: data, 21 | }); 22 | } catch (error) { 23 | return createErrorResponse({ error }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/app/traewelling/notifications/unread/route.ts: -------------------------------------------------------------------------------- 1 | import { authOptions } from '@/pages/api/auth/[...nextauth]'; 2 | import { TraewellingSdk } from '@/traewelling-sdk'; 3 | import createErrorResponse from '@/utils/api/createErrorResponse'; 4 | import createResponse from '@/utils/api/createResponse'; 5 | import { getServerSession } from 'next-auth'; 6 | 7 | export async function GET(request: Request) { 8 | try { 9 | const session = await getServerSession(authOptions); 10 | 11 | if (!session) { 12 | return createErrorResponse({ 13 | error: 'No session', 14 | statusCode: 401, 15 | }); 16 | } 17 | 18 | const data = await TraewellingSdk.notifications.getUnread(); 19 | 20 | return createResponse({ 21 | body: data, 22 | }); 23 | } catch (error) { 24 | return createErrorResponse({ error }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/app/traewelling/stations/[station]/route.ts: -------------------------------------------------------------------------------- 1 | import { authOptions } from '@/pages/api/auth/[...nextauth]'; 2 | import { TraewellingSdk } from '@/traewelling-sdk'; 3 | import { TransportType } from '@/traewelling-sdk/types'; 4 | import createErrorResponse from '@/utils/api/createErrorResponse'; 5 | import createResponse from '@/utils/api/createResponse'; 6 | import getSafeURLParams from '@/utils/api/getSafeUrlParams'; 7 | import { getServerSession } from 'next-auth'; 8 | 9 | const ALLOWED_TRANSPORT_TYPES = [ 10 | 'bus', 11 | 'express', 12 | 'ferry', 13 | 'regional', 14 | 'suburban', 15 | 'subway', 16 | 'taxi', 17 | 'tram', 18 | ]; 19 | 20 | export async function GET( 21 | request: Request, 22 | context: { params: { station: string } } 23 | ) { 24 | try { 25 | const session = await getServerSession(authOptions); 26 | 27 | const { transportType, from } = getSafeURLParams({ 28 | url: request.url, 29 | optionalParams: ['transportType', 'from'], 30 | requiredParams: [], 31 | }); 32 | 33 | if (!session) { 34 | return createErrorResponse({ 35 | error: 'No session', 36 | statusCode: 401, 37 | }); 38 | } 39 | 40 | if ( 41 | !(context.params.station ?? '').trim() || 42 | (!!transportType && !ALLOWED_TRANSPORT_TYPES.includes(transportType)) 43 | ) { 44 | return createErrorResponse({ 45 | error: 'Invalid parameters', 46 | statusCode: 400, 47 | }); 48 | } 49 | 50 | const data = await TraewellingSdk.station.departures({ 51 | id: +context.params.station, 52 | travelType: transportType as TransportType, 53 | when: from, 54 | }); 55 | return createResponse({ 56 | body: data, 57 | }); 58 | } catch (error) { 59 | return createErrorResponse({ error }); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/app/traewelling/stations/autocomplete/route.ts: -------------------------------------------------------------------------------- 1 | import { authOptions } from '@/pages/api/auth/[...nextauth]'; 2 | import { TraewellingSdk } from '@/traewelling-sdk'; 3 | import createErrorResponse from '@/utils/api/createErrorResponse'; 4 | import createResponse from '@/utils/api/createResponse'; 5 | import getSafeURLParams from '@/utils/api/getSafeUrlParams'; 6 | import { getServerSession } from 'next-auth'; 7 | 8 | export async function GET(request: Request) { 9 | try { 10 | const session = await getServerSession(authOptions); 11 | 12 | const { query } = getSafeURLParams({ 13 | url: request.url, 14 | requiredParams: ['query'], 15 | }); 16 | 17 | if (!session) { 18 | return createErrorResponse({ 19 | error: 'No session', 20 | statusCode: 401, 21 | }); 22 | } 23 | 24 | const data = await TraewellingSdk.trains.autocomplete({ query }); 25 | return createResponse({ 26 | body: data, 27 | }); 28 | } catch (error) { 29 | return createErrorResponse({ error }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/app/traewelling/stations/checkin/route.ts: -------------------------------------------------------------------------------- 1 | import { authOptions } from '@/pages/api/auth/[...nextauth]'; 2 | import { TraewellingSdk } from '@/traewelling-sdk'; 3 | import { CheckinInput } from '@/traewelling-sdk/functions/trains'; 4 | import createErrorResponse from '@/utils/api/createErrorResponse'; 5 | import createResponse from '@/utils/api/createResponse'; 6 | import { getServerSession } from 'next-auth'; 7 | 8 | export async function POST(request: Request) { 9 | try { 10 | const session = await getServerSession(authOptions); 11 | 12 | const body = (await request.json()) as CheckinInput; 13 | 14 | if (!session) { 15 | return createErrorResponse({ 16 | error: 'No session', 17 | statusCode: 401, 18 | }); 19 | } 20 | 21 | if (!body) { 22 | return createErrorResponse({ 23 | error: 'No body', 24 | statusCode: 400, 25 | }); 26 | } 27 | 28 | const data = await TraewellingSdk.trains.checkin(body); 29 | return createResponse({ 30 | body: data, 31 | }); 32 | } catch (error) { 33 | return createErrorResponse({ error }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/app/traewelling/stations/history/route.ts: -------------------------------------------------------------------------------- 1 | import { authOptions } from '@/pages/api/auth/[...nextauth]'; 2 | import { TraewellingSdk } from '@/traewelling-sdk'; 3 | import createErrorResponse from '@/utils/api/createErrorResponse'; 4 | import createResponse from '@/utils/api/createResponse'; 5 | import { getServerSession } from 'next-auth'; 6 | 7 | export async function GET(request: Request) { 8 | try { 9 | const session = await getServerSession(authOptions); 10 | 11 | if (!session) { 12 | return createErrorResponse({ 13 | error: 'No session', 14 | statusCode: 401, 15 | }); 16 | } 17 | 18 | const data = await TraewellingSdk.trains.history(); 19 | return createResponse({ 20 | body: data, 21 | }); 22 | } catch (error) { 23 | return createErrorResponse({ error }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/app/traewelling/stations/nearby/route.ts: -------------------------------------------------------------------------------- 1 | import { authOptions } from '@/pages/api/auth/[...nextauth]'; 2 | import { TraewellingSdk } from '@/traewelling-sdk'; 3 | import createErrorResponse from '@/utils/api/createErrorResponse'; 4 | import createResponse from '@/utils/api/createResponse'; 5 | import getSafeURLParams from '@/utils/api/getSafeUrlParams'; 6 | import { getServerSession } from 'next-auth'; 7 | 8 | export async function GET(request: Request) { 9 | try { 10 | const session = await getServerSession(authOptions); 11 | const { latitude, longitude } = getSafeURLParams({ 12 | url: request.url, 13 | requiredParams: ['latitude', 'longitude'], 14 | }); 15 | 16 | if (!session) { 17 | return createErrorResponse({ 18 | error: 'No session', 19 | statusCode: 401, 20 | }); 21 | } 22 | 23 | const data = await TraewellingSdk.trains.nearby({ 24 | latitude, 25 | longitude, 26 | }); 27 | return createResponse({ 28 | body: data, 29 | }); 30 | } catch (error) { 31 | return createErrorResponse({ error }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/app/traewelling/statuses/[status]/like/route.ts: -------------------------------------------------------------------------------- 1 | import { TraewellingSdk } from '@/traewelling-sdk'; 2 | import createErrorResponse from '@/utils/api/createErrorResponse'; 3 | import createResponse from '@/utils/api/createResponse'; 4 | 5 | export async function POST( 6 | _request: Request, 7 | context: { params: { status: string } } 8 | ) { 9 | try { 10 | if (!(context.params.status ?? '').trim()) { 11 | return createErrorResponse({ 12 | error: 'Invalid parameters', 13 | statusCode: 400, 14 | }); 15 | } 16 | 17 | const data = await TraewellingSdk.status.like({ 18 | id: context.params.status, 19 | method: 'POST', 20 | }); 21 | return createResponse({ 22 | body: data, 23 | }); 24 | } catch (error) { 25 | return createErrorResponse({ error }); 26 | } 27 | } 28 | 29 | export async function DELETE( 30 | _request: Request, 31 | context: { params: { status: string } } 32 | ) { 33 | try { 34 | if (!(context.params.status ?? '').trim()) { 35 | return createErrorResponse({ 36 | error: 'Invalid parameters', 37 | statusCode: 400, 38 | }); 39 | } 40 | 41 | const data = await TraewellingSdk.status.like({ 42 | id: context.params.status, 43 | method: 'DELETE', 44 | }); 45 | return createResponse({ 46 | body: data, 47 | }); 48 | } catch (error) { 49 | return createErrorResponse({ error }); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/app/traewelling/statuses/[status]/route.ts: -------------------------------------------------------------------------------- 1 | import { TraewellingSdk } from '@/traewelling-sdk'; 2 | import createErrorResponse from '@/utils/api/createErrorResponse'; 3 | import createResponse from '@/utils/api/createResponse'; 4 | 5 | export async function GET( 6 | _request: Request, 7 | context: { params: { status: string } } 8 | ) { 9 | try { 10 | if (!(context.params.status ?? '').trim()) { 11 | return createErrorResponse({ 12 | error: 'Invalid parameters', 13 | statusCode: 400, 14 | }); 15 | } 16 | 17 | // CAREFUL: Authorization is optional for this function! 18 | const data = await TraewellingSdk.status.single({ 19 | id: context.params.status, 20 | }); 21 | return createResponse({ 22 | body: data, 23 | }); 24 | } catch (error) { 25 | return createErrorResponse({ error }); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/app/traewelling/statuses/current/route.ts: -------------------------------------------------------------------------------- 1 | import { authOptions } from '@/pages/api/auth/[...nextauth]'; 2 | import { TraewellingSdk } from '@/traewelling-sdk'; 3 | import createErrorResponse from '@/utils/api/createErrorResponse'; 4 | import createResponse from '@/utils/api/createResponse'; 5 | import { getServerSession } from 'next-auth'; 6 | 7 | export async function GET(request: Request) { 8 | try { 9 | const session = await getServerSession(authOptions); 10 | 11 | if (!session) { 12 | return createErrorResponse({ 13 | error: 'No session', 14 | statusCode: 401, 15 | }); 16 | } 17 | 18 | const data = await TraewellingSdk.user.activeStatus(); 19 | return createResponse({ 20 | body: data, 21 | }); 22 | } catch (error) { 23 | return createErrorResponse({ error }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/app/traewelling/statuses/dashboard/me/route.ts: -------------------------------------------------------------------------------- 1 | import createResponse from '@/utils/api/createResponse'; 2 | 3 | export async function GET(request: Request) { 4 | return createResponse({ 5 | body: 'Hello World!', 6 | }); 7 | } 8 | -------------------------------------------------------------------------------- /src/app/traewelling/statuses/route.ts: -------------------------------------------------------------------------------- 1 | import { TraewellingSdk } from '@/traewelling-sdk'; 2 | import createErrorResponse from '@/utils/api/createErrorResponse'; 3 | import createResponse from '@/utils/api/createResponse'; 4 | 5 | export async function GET(request: Request) { 6 | try { 7 | // CAREFUL: Authorization is optional for this function! 8 | const data = await TraewellingSdk.status.dashboard(); 9 | 10 | return createResponse({ 11 | body: data, 12 | }); 13 | } catch (error) { 14 | return createErrorResponse({ error }); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/app/traewelling/trips/route.ts: -------------------------------------------------------------------------------- 1 | import { authOptions } from '@/pages/api/auth/[...nextauth]'; 2 | import { TraewellingSdk } from '@/traewelling-sdk'; 3 | import createErrorResponse from '@/utils/api/createErrorResponse'; 4 | import createResponse from '@/utils/api/createResponse'; 5 | import getSafeURLParams from '@/utils/api/getSafeUrlParams'; 6 | import { getServerSession } from 'next-auth'; 7 | 8 | export async function GET(request: Request) { 9 | try { 10 | const { hafasTripId, lineName, start } = getSafeURLParams({ 11 | url: request.url, 12 | requiredParams: ['hafasTripId', 'lineName', 'start'], 13 | }); 14 | 15 | const session = await getServerSession(authOptions); 16 | 17 | if (!session) { 18 | return createErrorResponse({ 19 | error: 'No session', 20 | statusCode: 401, 21 | }); 22 | } 23 | const data = await TraewellingSdk.trains.trip({ 24 | hafasTripId, 25 | lineName, 26 | start, 27 | }); 28 | 29 | return createResponse({ 30 | body: data, 31 | }); 32 | } catch (error) { 33 | return createErrorResponse({ error }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/app/traewelling/user/[username]/statuses/route.ts: -------------------------------------------------------------------------------- 1 | import { TraewellingSdk } from '@/traewelling-sdk'; 2 | import createErrorResponse from '@/utils/api/createErrorResponse'; 3 | import createResponse from '@/utils/api/createResponse'; 4 | 5 | export async function GET( 6 | request: Request, 7 | context: { params: { username: string } } 8 | ) { 9 | try { 10 | const data = await TraewellingSdk.user.getStatuses(context.params.username); 11 | 12 | return createResponse({ 13 | body: data, 14 | }); 15 | } catch (error) { 16 | return createErrorResponse({ error }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/app/u/[username]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | 3 | export default async function Layout({ children }: PropsWithChildren) { 4 | return
{children}
; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/u/[username]/page.tsx: -------------------------------------------------------------------------------- 1 | import Statuses from '@/components/Profile/Statuses/Statuses'; 2 | import { TraewellingSdk } from '@/traewelling-sdk'; 3 | import { Metadata } from 'next'; 4 | import { notFound } from 'next/navigation'; 5 | 6 | async function getUserProfileData(username: string) { 7 | const userData = await TraewellingSdk.user.get(username); 8 | 9 | return userData; 10 | } 11 | 12 | async function getUserStatuses(username: string) { 13 | const statuses = await TraewellingSdk.user.getStatuses(username); 14 | 15 | return statuses; 16 | } 17 | 18 | export async function generateMetadata({ 19 | params, 20 | }: { 21 | params: { username: string }; 22 | }): Promise { 23 | const userData = await getUserProfileData(params.username); 24 | 25 | return { title: `${userData?.displayName} - aboard.at` }; 26 | } 27 | 28 | export default async function Page({ 29 | params, 30 | }: { 31 | params: { username: string }; 32 | }) { 33 | const userData = await getUserProfileData(params.username); 34 | const statuses = await getUserStatuses(params.username); 35 | 36 | if (userData === null) notFound(); 37 | 38 | return ( 39 |
40 |

User

41 |
{JSON.stringify(userData, null, 2)}
42 |

Statuses

43 | 44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/app/u/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from 'next/navigation'; 2 | 3 | export default async function Page() { 4 | notFound(); 5 | } 6 | -------------------------------------------------------------------------------- /src/components/AuthGuard/AuthGuard.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Login from '@/components/Login/Login'; 4 | import { useSession } from 'next-auth/react'; 5 | import { ReactNode } from 'react'; 6 | 7 | export const AuthGuard = ({ children }: { children: ReactNode }) => { 8 | const { status } = useSession(); 9 | 10 | if (status !== 'loading') { 11 | if (status === 'unauthenticated') { 12 | return ; 13 | } 14 | 15 | return <>{children}; 16 | } 17 | 18 | return
Loading
; 19 | }; 20 | -------------------------------------------------------------------------------- /src/components/Button/Button.module.scss: -------------------------------------------------------------------------------- 1 | .base { 2 | --button-active: var(--slate-6); 3 | --button-base: var(--slate-4); 4 | --button-color: var(--slate-11); 5 | --button-hover: var(--slate-5); 6 | 7 | appearance: none; 8 | align-items: center; 9 | background-color: var(--button-base); 10 | border: none; 11 | border-radius: 0.375rem; 12 | color: var(--button-color); 13 | cursor: pointer; 14 | display: flex; 15 | flex-shrink: 0; 16 | font-weight: 500; 17 | height: 2.3125rem; 18 | justify-content: center; 19 | padding: 0 1rem; 20 | user-select: none; 21 | 22 | &:active { 23 | background-color: var(--button-active); 24 | } 25 | 26 | &:hover { 27 | background-color: var(--button-hover); 28 | } 29 | 30 | &:disabled, 31 | &[disabled] { 32 | opacity: 0.5; 33 | pointer-events: none; 34 | } 35 | 36 | &.error { 37 | --button-active: var(--crimson-6); 38 | --button-base: var(--crimson-4); 39 | --button-color: var(--crimson-11); 40 | --button-hover: var(--crimson-5); 41 | } 42 | 43 | &.primary { 44 | --button-active: var(--sky-6); 45 | --button-base: var(--sky-4); 46 | --button-color: var(--sky-11); 47 | --button-hover: var(--sky-5); 48 | } 49 | 50 | &.secondary { 51 | --button-active: #9E0A5B66; 52 | --button-base: #9E0A5B20; 53 | --button-color: #9E0A5B; 54 | --button-hover: #9E0A5B40; 55 | } 56 | 57 | &.success { 58 | --button-active: var(--green-6); 59 | --button-base: var(--green-4); 60 | --button-color: var(--green-11); 61 | --button-hover: var(--green-5); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/components/Button/Button.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import styles from './Button.module.scss'; 3 | import { ButtonProps } from './types'; 4 | 5 | const Button = ({ 6 | children, 7 | className, 8 | disabled, 9 | onClick, 10 | variant, 11 | }: ButtonProps) => { 12 | return ( 13 | 20 | ); 21 | }; 22 | 23 | export default Button; 24 | -------------------------------------------------------------------------------- /src/components/Button/types.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | type ButtonVariant = 'error' | 'primary' | 'secondary' | 'success'; 4 | 5 | export type ButtonProps = { 6 | children: ReactNode; 7 | className?: string; 8 | disabled?: boolean; 9 | onClick?: () => void; 10 | variant?: ButtonVariant; 11 | }; 12 | -------------------------------------------------------------------------------- /src/components/CheckIn/CheckIn.context.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | import { CheckInContextValue } from './types'; 3 | 4 | export const CheckInContext = createContext({ 5 | checkIn: () => void 0, 6 | currentStatus: undefined, 7 | departureTime: undefined, 8 | destination: undefined, 9 | error: undefined, 10 | goBack: () => void 0, 11 | isOpen: false, 12 | message: '', 13 | origin: undefined, 14 | query: '', 15 | setDepartureTime: () => void 0, 16 | setDestination: () => void 0, 17 | setIsOpen: () => void 0, 18 | setMessage: () => void 0, 19 | setOrigin: () => void 0, 20 | setQuery: () => void 0, 21 | setTravelType: () => void 0, 22 | setTrip: () => void 0, 23 | setVisibility: () => void 0, 24 | step: 'origin', 25 | travelType: 0, 26 | trip: undefined, 27 | visibility: 0, 28 | }); 29 | -------------------------------------------------------------------------------- /src/components/CheckIn/CheckIn.module.scss: -------------------------------------------------------------------------------- 1 | .base { 2 | display: contents; 3 | } 4 | 5 | .statusLink { 6 | color: unset; 7 | text-decoration: none; 8 | } 9 | -------------------------------------------------------------------------------- /src/components/CheckIn/CurrentStatus/CurrentStatus.module.scss: -------------------------------------------------------------------------------- 1 | .base { 2 | color: var(--contrast, #fff); 3 | padding-bottom: calc(1rem + env(safe-area-inset-bottom)); 4 | } 5 | 6 | .destination, 7 | .origin { 8 | margin-inline: 1rem; 9 | padding-left: 1.625rem; 10 | position: relative; 11 | 12 | &::after { 13 | background-color: var(--accent, var(--sky-11)); 14 | border-radius: 50%; 15 | box-shadow: 0 0 0 2px var(--contrast, #fff); 16 | content: ''; 17 | display: block; 18 | height: 6px; 19 | left: 2px; 20 | position: absolute; 21 | width: 6px; 22 | } 23 | 24 | &::before { 25 | background-color: var(--contrast, #fff); 26 | content: ''; 27 | display: block; 28 | height: calc(100% - 0.5625rem); 29 | left: 4px; 30 | position: absolute; 31 | width: 2px; 32 | } 33 | 34 | .station { 35 | align-items: flex-start; 36 | display: flex; 37 | justify-content: space-between; 38 | } 39 | 40 | .time { 41 | font-weight: 500; 42 | opacity: 0.85; 43 | padding-left: 0.5rem; 44 | white-space: nowrap; 45 | } 46 | } 47 | 48 | .origin { 49 | &::after, 50 | &::before { 51 | top: 0.5625rem; 52 | } 53 | } 54 | 55 | .destination { 56 | padding-bottom: 0; 57 | 58 | &::after, 59 | &::before { 60 | top: 0.5625rem; 61 | } 62 | 63 | &::before { 64 | height: 0.5625rem; 65 | top: 0; 66 | } 67 | } 68 | 69 | .meta { 70 | align-items: center; 71 | color: rgba(var(--contrast-rgb, 255, 255, 255), 0.5); 72 | display: flex; 73 | font-size: 0.875rem; 74 | font-weight: 600; 75 | gap: 1rem; 76 | padding: 0.5rem; 77 | padding-left: 0.6875rem; 78 | position: relative; 79 | 80 | &::before { 81 | background-color: var(--contrast, #fff); 82 | content: ''; 83 | display: block; 84 | height: calc(100%); 85 | left: 20px; 86 | position: absolute; 87 | top: 0; 88 | width: 2px; 89 | z-index: -1; 90 | } 91 | 92 | div { 93 | align-items: center; 94 | display: flex; 95 | gap: 0.25rem; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/components/CheckIn/CurrentStatus/CurrentStatus.tsx: -------------------------------------------------------------------------------- 1 | import { NewLineIndicator } from '@/components/NewLineIndicator/NewLineIndicator'; 2 | import { parseSchedule } from '@/utils/parseSchedule'; 3 | import { useContext } from 'react'; 4 | import { MdOutlineToken } from 'react-icons/md'; 5 | import { TbRoute } from 'react-icons/tb'; 6 | import { CheckInContext } from '../CheckIn.context'; 7 | import styles from './CurrentStatus.module.scss'; 8 | 9 | const CurrentStatus = () => { 10 | const { currentStatus } = useContext(CheckInContext); 11 | 12 | if (!currentStatus) { 13 | return null; 14 | } 15 | 16 | const arrivalSchedule = parseSchedule({ 17 | actual: 18 | currentStatus.journey.manualArrival ?? 19 | currentStatus.journey.destination.arrival.actual, 20 | planned: currentStatus.journey.destination.arrival.planned!, 21 | }); 22 | 23 | const departureSchedule = parseSchedule({ 24 | actual: 25 | currentStatus.journey.manualDeparture ?? 26 | currentStatus.journey.origin.departure.actual, 27 | planned: currentStatus.journey.origin.departure.planned!, 28 | }); 29 | 30 | return ( 31 |
32 |
33 |
34 | {currentStatus.journey.origin.station.name} 35 | 36 | ab {departureSchedule.planned} 37 | {!departureSchedule.isOnTime && ( 38 | 39 |  +{departureSchedule.delayInMinutes} 40 | 41 | )} 42 | 43 |
44 |
45 | 46 |
47 | 48 | 49 |
50 | 51 | {Math.ceil(currentStatus.journey.distance / 1000)} km 52 |
53 | 54 |
55 | 56 | {currentStatus.journey.pointsAwarded} 57 |
58 |
59 | 60 |
61 |
62 | {currentStatus.journey.destination.station.name} 63 | 64 | an {arrivalSchedule.planned} 65 | {!arrivalSchedule.isOnTime && ( 66 | 67 |  +{arrivalSchedule.delayInMinutes} 68 | 69 | )} 70 | 71 |
72 |
73 |
74 | ); 75 | }; 76 | 77 | export default CurrentStatus; 78 | -------------------------------------------------------------------------------- /src/components/CheckIn/DestinationStep/types.ts: -------------------------------------------------------------------------------- 1 | export type StopProps = { 2 | arrivalAt: string | null; 3 | isCancelled: boolean; 4 | isDelayed: boolean; 5 | name: string; 6 | onClick?: () => void; 7 | plannedArrivalAt: string | null; 8 | }; 9 | -------------------------------------------------------------------------------- /src/components/CheckIn/NewCurrentStatus/NewCurrentStatus.module.scss: -------------------------------------------------------------------------------- 1 | .base { 2 | color: var(--contrast); 3 | margin-top: -2.375rem; 4 | padding-top: 2.375rem; 5 | } 6 | 7 | .decoration { 8 | align-items: center; 9 | display: flex; 10 | height: 1.40625rem; 11 | } 12 | 13 | .destination { 14 | align-items: flex-start; 15 | display: flex; 16 | padding: 0.75rem 1rem 1rem 0; 17 | } 18 | 19 | .line { 20 | align-self: center; 21 | border-top: 2px solid var(--contrast); 22 | flex-shrink: 0; 23 | margin-right: -1px; 24 | width: calc(1.5rem + 1px); 25 | } 26 | 27 | .metadata { 28 | align-items: center; 29 | color: rgba(var(--contrast-rgb, 255, 255, 255), 0.75); 30 | display: flex; 31 | font-size: 0.875rem; 32 | font-weight: 500; 33 | gap: 0.25rem; 34 | } 35 | 36 | .metadataContainer { 37 | align-items: center; 38 | display: flex; 39 | gap: 0.75rem; 40 | margin-left: 1px; 41 | padding-inline: 1rem; 42 | } 43 | 44 | .station { 45 | font-size: 1.125rem; 46 | font-weight: 600; 47 | line-height: 1.25; 48 | margin-left: 0.625rem; 49 | margin-right: auto; 50 | padding-right: 1rem; 51 | } 52 | 53 | .stopIndicator { 54 | align-self: center; 55 | background-color: var(--accent); 56 | border-radius: 50%; 57 | box-shadow: 0 0 0 2px var(--contrast); 58 | flex-shrink: 0; 59 | height: 0.375rem; 60 | width: 0.375rem; 61 | } 62 | 63 | .time { 64 | margin-top: 3.25px; 65 | } 66 | -------------------------------------------------------------------------------- /src/components/CheckIn/NewCurrentStatus/NewCurrentStatus.tsx: -------------------------------------------------------------------------------- 1 | import { NewLineIndicator } from '@/components/NewLineIndicator/NewLineIndicator'; 2 | import { Time } from '@/components/Time/Time'; 3 | import { radioCanada } from '@/styles/fonts'; 4 | import { parseSchedule } from '@/utils/parseSchedule'; 5 | import clsx from 'clsx'; 6 | import { useContext } from 'react'; 7 | import { MdOutlineToken } from 'react-icons/md'; 8 | import { TbRoute } from 'react-icons/tb'; 9 | import { CheckInContext } from '../CheckIn.context'; 10 | import styles from './NewCurrentStatus.module.scss'; 11 | 12 | export const NewCurrentStatus = () => { 13 | const { currentStatus } = useContext(CheckInContext); 14 | 15 | if (!currentStatus) { 16 | return null; 17 | } 18 | 19 | const { journey } = currentStatus; 20 | 21 | const arrivalSchedule = parseSchedule({ 22 | actual: journey.manualArrival ?? journey.destination.arrival.actual, 23 | planned: journey.destination.arrival.planned!, 24 | }); 25 | 26 | const departureSchedule = parseSchedule({ 27 | actual: journey.manualDeparture ?? journey.origin.departure.actual, 28 | planned: journey.origin.departure.planned!, 29 | }); 30 | const travelTime = 31 | arrivalSchedule.actualValue - departureSchedule.actualValue; 32 | const timePassed = Date.now() - departureSchedule.actualValue; 33 | const progress = Math.max(0, Math.min(timePassed * (100 / travelTime), 100)); 34 | 35 | return ( 36 |
40 |
41 | 42 | 43 |
44 | 45 | {Math.ceil(journey.distance / 1000)} km 46 |
47 | 48 |
49 | 50 | {journey.pointsAwarded} 51 |
52 |
53 | 54 |
55 |
56 |
57 |
58 |
59 | 60 | 61 | {journey.destination.station.name} 62 | 63 | 64 |
71 |
72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /src/components/CheckIn/OriginStep/OriginStep.module.scss: -------------------------------------------------------------------------------- 1 | .base { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 1rem; 5 | padding: calc(1rem + env(safe-area-inset-top)) 0 calc(1rem + env(safe-area-inset-bottom)); 6 | 7 | &.isOpen { 8 | height: 100%; 9 | padding-bottom: 0; 10 | } 11 | } 12 | 13 | .container { 14 | padding-inline: 1rem; 15 | } 16 | 17 | .title { 18 | color: var(--sky-11); 19 | font-size: 0.875rem; 20 | font-weight: 600; 21 | text-transform: uppercase; 22 | } 23 | 24 | .scrollArea { 25 | height: 100%; 26 | } 27 | 28 | .scrollContent { 29 | display: flex; 30 | flex-direction: column; 31 | gap: 1rem; 32 | } 33 | 34 | .stationList { 35 | display: flex; 36 | flex-direction: column; 37 | list-style: none; 38 | margin-inline: -1rem; 39 | max-height: 100%; 40 | padding: 0; 41 | 42 | li:not(:first-child) > .station { 43 | margin-top: 3px; 44 | 45 | &::after { 46 | border-top: 1px solid var(--slate-4); 47 | content: ''; 48 | display: block; 49 | left: 1rem; 50 | position: absolute; 51 | right: 1rem; 52 | top: -2px; 53 | } 54 | } 55 | } 56 | 57 | .station { 58 | align-items: center; 59 | appearance: none; 60 | background: none; 61 | border: none; 62 | color: var(--slate-12); 63 | cursor: pointer; 64 | display: flex; 65 | height: 3rem; 66 | padding: 0 1rem; 67 | position: relative; 68 | text-align: left; 69 | user-select: none; 70 | width: 100%; 71 | 72 | &:active { 73 | background-color: var(--slate-5); 74 | } 75 | 76 | &:hover { 77 | background-color: var(--slate-4); 78 | } 79 | 80 | &.isSkeleton { 81 | pointer-events: none; 82 | } 83 | 84 | mark { 85 | background-color: var(--sky-6); 86 | border-radius: 0.25rem; 87 | color: var(--blue12); 88 | // padding-inline: 0.25rem; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/components/CheckIn/OriginStep/types.ts: -------------------------------------------------------------------------------- 1 | export type StationProps = { 2 | name: string; 3 | onClick?: () => void; 4 | query?: string; 5 | rilIdentifier: string | null; 6 | }; 7 | -------------------------------------------------------------------------------- /src/components/CheckIn/Panel/Panel.module.scss: -------------------------------------------------------------------------------- 1 | .base { 2 | bottom: 0; 3 | padding-bottom: env(safe-area-inset-bottom); 4 | position: fixed; 5 | width: 100%; 6 | 7 | @media screen and (min-width: 569px) { 8 | max-width: var(--layout-width); 9 | } 10 | 11 | &.isOpen { 12 | background-color: #fff; 13 | border-radius: 0; 14 | height: 100vh; 15 | top: 0; 16 | 17 | @supports (height: 100dvh) { 18 | height: 100dvh; 19 | } 20 | } 21 | 22 | &.hasCurrentStatus:not(.isOpen) { 23 | background-color: var(--accent); 24 | border-radius: 0.5rem 0.5rem 0 0; 25 | box-shadow: 26 | rgba(0, 0, 0, 0.16) 0px -10px 36px 0px, 27 | rgba(0, 0, 0, 0.06) 0px 0px 0px 1px; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/components/CheckIn/Panel/Panel.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { useContext } from 'react'; 3 | import { CheckInContext } from '../CheckIn.context'; 4 | import styles from './Panel.module.scss'; 5 | import { PanelProps } from './types'; 6 | 7 | const Panel = ({ children }: PanelProps) => { 8 | const { currentStatus, isOpen } = useContext(CheckInContext); 9 | 10 | return ( 11 |
18 | {children} 19 |
20 | ); 21 | }; 22 | 23 | export default Panel; 24 | -------------------------------------------------------------------------------- /src/components/CheckIn/Panel/types.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | export type PanelProps = { 4 | children: ReactNode; 5 | }; 6 | -------------------------------------------------------------------------------- /src/components/CheckIn/Search/Search.module.scss: -------------------------------------------------------------------------------- 1 | .base { 2 | background-color: #fff; 3 | border-radius: 0.375rem; 4 | box-shadow: rgba(99, 99, 99, 0.2) 0 0.125rem 0.5rem 0; // CSS Scan #6 5 | color: var(--slate-12); 6 | cursor: pointer; 7 | display: flex; 8 | height: 2.75rem; 9 | 10 | &.isAttached { 11 | margin-top: -2.375rem; 12 | 13 | .input { 14 | pointer-events: none; 15 | } 16 | } 17 | } 18 | 19 | .input { 20 | border: none; 21 | flex-grow: 1; 22 | outline: none; 23 | padding-inline: 1rem; 24 | } 25 | 26 | .leftAddon, 27 | .rightAddon { 28 | align-items: center; 29 | appearance: none; 30 | background: none; 31 | border: none; 32 | color: inherit; 33 | cursor: pointer; 34 | display: flex; 35 | flex-shrink: 0; 36 | justify-content: center; 37 | padding-inline: 1rem; 38 | position: relative; 39 | } 40 | 41 | .leftAddon::after, 42 | .rightAddon::before { 43 | background-color: var(--slate-4); 44 | content: ''; 45 | display: block; 46 | height: 1.5rem; 47 | position: absolute; 48 | width: 1px; 49 | } 50 | 51 | .leftAddon::after { 52 | right: 0; 53 | } 54 | 55 | .rightAddon::before { 56 | left: 0; 57 | } 58 | -------------------------------------------------------------------------------- /src/components/CheckIn/Search/Search.tsx: -------------------------------------------------------------------------------- 1 | import useUmami from '@/hooks/useUmami/useUmami'; 2 | import { NearbyResponse } from '@/traewelling-sdk/functions/trains'; 3 | import { debounce } from '@/utils/debounce'; 4 | import clsx from 'clsx'; 5 | import { 6 | ChangeEventHandler, 7 | MouseEventHandler, 8 | useContext, 9 | useEffect, 10 | useRef, 11 | } from 'react'; 12 | import { MdArrowBack, MdMyLocation, MdSearch } from 'react-icons/md'; 13 | import { CheckInContext } from '../CheckIn.context'; 14 | import styles from './Search.module.scss'; 15 | 16 | const Search = () => { 17 | const { goBack, isOpen, setIsOpen, setOrigin, setQuery } = 18 | useContext(CheckInContext); 19 | const inputRef = useRef(null); 20 | 21 | const { simpleEvent } = useUmami(); 22 | 23 | useEffect(() => { 24 | if (isOpen) { 25 | inputRef.current?.focus(); 26 | } 27 | }, [isOpen]); 28 | 29 | const handleBackClick: MouseEventHandler = (event) => { 30 | if (!isOpen) { 31 | return; 32 | } 33 | 34 | event.stopPropagation(); 35 | goBack(); 36 | }; 37 | 38 | const handleBaseClick = () => { 39 | setIsOpen(true); 40 | }; 41 | 42 | const handleChange: ChangeEventHandler = ({ target }) => { 43 | debounce(() => setQuery(target.value), 500)(); 44 | }; 45 | 46 | const handleNearbyClick: MouseEventHandler = (event) => { 47 | if (isOpen) { 48 | event.stopPropagation(); 49 | } 50 | 51 | navigator.geolocation.getCurrentPosition(async ({ coords }) => { 52 | const response = await fetch( 53 | `/traewelling/stations/nearby?latitude=${coords.latitude}&longitude=${coords.longitude}` 54 | ); 55 | 56 | if (!response.ok) { 57 | return; 58 | } 59 | 60 | simpleEvent('nearby_clicked'); 61 | 62 | const station = (await response.json()) as NearbyResponse; 63 | setOrigin({ 64 | id: station.id, 65 | ibnr: station.ibnr, 66 | name: station.name, 67 | rilIdentifier: station.rilIdentifier, 68 | }); 69 | }); 70 | }; 71 | 72 | return ( 73 |
77 | 80 | 81 | 87 | 88 | 91 |
92 | ); 93 | }; 94 | 95 | export default Search; 96 | -------------------------------------------------------------------------------- /src/components/CheckIn/consts.ts: -------------------------------------------------------------------------------- 1 | import { HAFASProductType } from '@/traewelling-sdk/hafasTypes'; 2 | import { AboardMethod } from '@/types/aboard'; 3 | import { ReactNode } from 'react'; 4 | import ProductIcon from '../ProductIcon/ProductIcon'; 5 | import { ProductIconProps } from '../ProductIcon/types'; 6 | 7 | export const METHOD_ICONS: Record< 8 | AboardMethod, 9 | (props: ProductIconProps) => ReactNode 10 | > = { 11 | bus: ProductIcon.Bus, 12 | ferry: ProductIcon.Ferry, 13 | national: ProductIcon.Other, 14 | 'national-express': ProductIcon.Other, 15 | regional: ProductIcon.Other, 16 | 'regional-express': ProductIcon.Other, 17 | suburban: ProductIcon.Suburban, 18 | subway: ProductIcon.Subway, 19 | taxi: ProductIcon.Other, 20 | tram: ProductIcon.Tram, 21 | }; 22 | 23 | export const PRODUCT_ICONS: Record< 24 | HAFASProductType, 25 | (props: ProductIconProps) => ReactNode 26 | > = { 27 | bus: ProductIcon.Bus, 28 | ferry: ProductIcon.Other, 29 | national: ProductIcon.Other, 30 | nationalExpress: ProductIcon.Other, 31 | regional: ProductIcon.Other, 32 | regionalExp: ProductIcon.Other, 33 | suburban: ProductIcon.Suburban, 34 | subway: ProductIcon.Subway, 35 | taxi: ProductIcon.Other, 36 | tram: ProductIcon.Tram, 37 | }; 38 | -------------------------------------------------------------------------------- /src/components/CheckIn/types.ts: -------------------------------------------------------------------------------- 1 | import { Station, Stop } from '@/traewelling-sdk/types'; 2 | import { AboardStatus, AboardTrip } from '@/types/aboard'; 3 | 4 | export type CheckInContextValue = { 5 | checkIn: () => void; 6 | currentStatus: AboardStatus | null | undefined; 7 | departureTime: string | undefined; 8 | destination: Stop | undefined; 9 | error: string | undefined; // TODO: Temporary solution 10 | goBack: () => void; 11 | isOpen: boolean; 12 | message: string; 13 | origin: Pick | undefined; 14 | query: string; 15 | setDepartureTime: (value: string | undefined) => void; 16 | setDestination: (value: Stop | undefined) => void; 17 | setIsOpen: (value: boolean) => void; 18 | setMessage: (value: string) => void; 19 | setOrigin: ( 20 | value: Pick | undefined 21 | ) => void; 22 | setQuery: (value: string) => void; 23 | setTravelType: (value: number) => void; 24 | setTrip: (value: AboardTrip | undefined) => void; 25 | setVisibility: (value: number) => void; 26 | step: CheckInStep; 27 | travelType: number; 28 | trip: AboardTrip | undefined; 29 | visibility: number; 30 | }; 31 | 32 | export type CheckInStep = 'origin' | 'trip' | 'destination' | 'final'; 33 | -------------------------------------------------------------------------------- /src/components/FilterButton/FilterButton.module.scss: -------------------------------------------------------------------------------- 1 | .base { 2 | align-items: center; 3 | appearance: none; 4 | background-color: var(--sky-2); 5 | border: none; 6 | border-radius: 9999rem; 7 | color: var(--sky-11); 8 | cursor: pointer; 9 | display: inline-flex; 10 | font-size: 0.875rem; 11 | font-weight: 500; 12 | gap: 0.375rem; 13 | height: 2rem; 14 | padding-inline: 0.75rem; 15 | white-space: nowrap; 16 | 17 | &:first-of-type { 18 | margin-left: 1rem; 19 | } 20 | 21 | &.isActive { 22 | background-color: var(--sky-12); 23 | color: #fff; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/components/FilterButton/FilterButton.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { MdClose } from 'react-icons/md'; 3 | import styles from './FilterButton.module.scss'; 4 | import { FilterButtonProps } from './types'; 5 | 6 | const FilterButton = ({ 7 | children, 8 | className, 9 | isActive, 10 | onClick, 11 | value, 12 | }: FilterButtonProps) => { 13 | return ( 14 | 21 | ); 22 | }; 23 | 24 | export default FilterButton; 25 | -------------------------------------------------------------------------------- /src/components/FilterButton/types.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | export type FilterButtonProps = { 4 | children: ReactNode; 5 | className?: string; 6 | isActive: boolean; 7 | onClick: (value: any) => void; 8 | value: any; 9 | }; 10 | -------------------------------------------------------------------------------- /src/components/FullscreenLoading/FullscreenLoading.module.scss: -------------------------------------------------------------------------------- 1 | .base { 2 | align-items: center; 3 | background-color: #fff; 4 | display: flex; 5 | height: 100%; 6 | justify-content: center; 7 | left: 0; 8 | position: fixed; 9 | top: 0; 10 | width: 100%; 11 | z-index: 1000; 12 | } 13 | 14 | @keyframes spin { 15 | to { 16 | transform: rotate(360deg); 17 | } 18 | } 19 | 20 | .spinner { 21 | animation: spin 1s linear infinite; 22 | border-radius: 50%; 23 | border: 0.25rem solid var(--sky-11); 24 | border-top-color: transparent; 25 | height: 4rem; 26 | width: 4rem; 27 | } 28 | -------------------------------------------------------------------------------- /src/components/FullscreenLoading/FullscreenLoading.tsx: -------------------------------------------------------------------------------- 1 | import LockBodyScroll from '../LockBodyScroll/LockBodyScroll'; 2 | import styles from './FullscreenLoading.module.scss'; 3 | 4 | const FullscreenLoading = () => { 5 | return ( 6 | <> 7 |
8 |
9 |
10 | 11 | 12 | ); 13 | }; 14 | 15 | export default FullscreenLoading; 16 | -------------------------------------------------------------------------------- /src/components/IconSkew/IconSkew.module.scss: -------------------------------------------------------------------------------- 1 | .base { 2 | align-items: flex-end; 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | 7 | .row { 8 | display: flex; 9 | } 10 | 11 | .cell { 12 | display: contents; 13 | } 14 | -------------------------------------------------------------------------------- /src/components/IconSkew/IconSkew.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { Children } from 'react'; 3 | import styles from './IconSkew.module.scss'; 4 | import { IconSkewProps } from './types'; 5 | 6 | const IconSkew = ({ children, className, gap, size }: IconSkewProps) => { 7 | const items = [...Array(size)].map((_, row) => ( 8 |
9 | {[...Array(row + 1)].map((_, column) => ( 10 |
11 | {Children.toArray(children).at(column % Children.count(children))} 12 |
13 | ))} 14 |
15 | )); 16 | 17 | return ( 18 |
19 | {items} 20 |
21 | ); 22 | }; 23 | 24 | export default IconSkew; 25 | -------------------------------------------------------------------------------- /src/components/IconSkew/types.ts: -------------------------------------------------------------------------------- 1 | import { CSSProperties, ReactNode } from 'react'; 2 | 3 | export type IconSkewProps = { 4 | children: ReactNode; 5 | className?: string; 6 | gap: CSSProperties['gap']; 7 | size: number; 8 | }; 9 | -------------------------------------------------------------------------------- /src/components/Layout/Layout.module.scss: -------------------------------------------------------------------------------- 1 | .base { 2 | overflow-x: visible; 3 | margin: 0 auto; 4 | display: block; 5 | position: relative; 6 | 7 | @media screen and (min-width: 569px) { 8 | max-width: var(--layout-width); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/components/Layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | import styles from './Layout.module.scss'; 3 | 4 | const Layout = ({ children }: PropsWithChildren) => { 5 | return
{children}
; 6 | }; 7 | 8 | export default Layout; 9 | -------------------------------------------------------------------------------- /src/components/Layout/iphone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunrisesdev/aboard/59a13a30ad57c37c8283fbc873a43edcff48316d/src/components/Layout/iphone.png -------------------------------------------------------------------------------- /src/components/LegacyTime/LegacyTime.tsx: -------------------------------------------------------------------------------- 1 | import { figtree } from '@/styles/fonts'; 2 | import clsx from 'clsx'; 3 | import { LegacyTimeProps } from './types'; 4 | 5 | const LegacyTime = ({ children, className }: LegacyTimeProps) => { 6 | return {children}; 7 | }; 8 | 9 | export default LegacyTime; 10 | -------------------------------------------------------------------------------- /src/components/LegacyTime/types.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | export type LegacyTimeProps = { 4 | children: ReactNode; 5 | className?: string; 6 | }; 7 | -------------------------------------------------------------------------------- /src/components/LineIndicator/LineIndicator.module.scss: -------------------------------------------------------------------------------- 1 | .base { 2 | align-items: center; 3 | background-color: var(--accent); 4 | border-radius: 99rem; 5 | color: var(--contrast); 6 | display: flex; 7 | flex-shrink: 0; 8 | font-size: 0.75rem; 9 | font-weight: 600; 10 | height: 1.25rem; 11 | line-height: 1; 12 | min-width: 1.75rem; 13 | justify-content: center; 14 | padding: 0 0.375rem; 15 | white-space: nowrap; 16 | 17 | &.isInverted { 18 | background-color: var(--contrast); 19 | color: var(--accent); 20 | } 21 | 22 | &.isRectangular { 23 | border-radius: 0.25rem; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/components/LineIndicator/LineIndicator.tsx: -------------------------------------------------------------------------------- 1 | import { getLineTheme } from '@/helpers/getLineTheme/getLineTheme'; 2 | import { inter } from '@/styles/fonts'; 3 | import { HAFASProductType } from '@/traewelling-sdk/hafasTypes'; 4 | import clsx from 'clsx'; 5 | import ThemeProvider from '../ThemeProvider/ThemeProvider'; 6 | import styles from './LineIndicator.module.scss'; 7 | import { LineIndicatorProps } from './types'; 8 | 9 | const HIDDEN_PRODUCT_NAMES = ['Bus', 'STB', 'STR']; 10 | const RECTANGULAR_PRODUCTS: HAFASProductType[] = [ 11 | 'national', 12 | 'nationalExpress', 13 | 'regional', 14 | 'regionalExp', 15 | ]; 16 | 17 | const LineIndicator = ({ 18 | className, 19 | isInverted = false, 20 | lineId, 21 | lineName, 22 | product, 23 | }: LineIndicatorProps) => { 24 | const displayName = lineName 25 | .replaceAll(new RegExp(`^(${HIDDEN_PRODUCT_NAMES.join('|')})`, 'gi'), '') 26 | .trim(); 27 | 28 | const theme = getLineTheme(lineId, product); 29 | 30 | return ( 31 | 32 |
41 | {displayName} 42 |
43 |
44 | ); 45 | }; 46 | 47 | export default LineIndicator; 48 | -------------------------------------------------------------------------------- /src/components/LineIndicator/types.ts: -------------------------------------------------------------------------------- 1 | import { HAFASProductType } from '@/traewelling-sdk/hafasTypes'; 2 | 3 | export type LineIndicatorProps = { 4 | className?: string; 5 | isInverted?: boolean; 6 | lineId: string; 7 | lineName: string; 8 | product: HAFASProductType; 9 | }; 10 | -------------------------------------------------------------------------------- /src/components/LockBodyScroll/LockBodyScroll.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import useLockBodyScroll from '@/hooks/useLockBodyScroll/useLockBodyScroll'; 4 | import { Fragment } from 'react'; 5 | 6 | const LockBodyScroll = () => { 7 | useLockBodyScroll(); 8 | 9 | return ; 10 | }; 11 | 12 | export default LockBodyScroll; 13 | -------------------------------------------------------------------------------- /src/components/Login/Login.module.scss: -------------------------------------------------------------------------------- 1 | .base { 2 | display: flex; 3 | flex-direction: column; 4 | height: 100vh; 5 | } 6 | 7 | .keyVisual { 8 | background-image: url('/freiburg.png'); 9 | background-position: center; 10 | background-size: cover; 11 | height: 7.5rem; 12 | flex-shrink: 0; 13 | } 14 | 15 | .hero { 16 | align-items: center; 17 | background-color: var(--black-a6); 18 | backdrop-filter: blur(0.125rem); 19 | display: flex; 20 | height: 100%; 21 | justify-content: center; 22 | padding: 1rem; 23 | width: 100%; 24 | } 25 | 26 | .title { 27 | color: #fff; 28 | font-size: 1.75rem; 29 | font-weight: 700; 30 | line-height: 1; 31 | margin: 0; 32 | text-align: center; 33 | text-shadow: 34 | 0 0 1rem var(--black-a11), 35 | 0 0 0.25rem var(--black-a12); 36 | transform: skew(-6deg); 37 | } 38 | 39 | .content { 40 | align-items: center; 41 | box-shadow: 42 | 0 -16px 15px -3px rgba(0, 0, 0, 0.1), 43 | 0 -4px 6px -2px rgba(0, 0, 0, 0.05); 44 | display: flex; 45 | flex-direction: column; 46 | gap: 2rem; 47 | height: 100%; 48 | line-height: 1.75; 49 | padding: 2rem; 50 | text-align: center; 51 | 52 | a { 53 | color: var(--sky-11); 54 | text-decoration: none; 55 | } 56 | } 57 | 58 | .logo { 59 | align-items: center; 60 | border: 1px dashed var(--sky-6); 61 | border-radius: 0.375rem; 62 | color: #272727; 63 | display: flex; 64 | font-weight: 600; 65 | height: 4rem; 66 | justify-content: center; 67 | width: 60%; 68 | } 69 | 70 | .brand { 71 | align-self: flex-start; 72 | display: flex; 73 | flex-direction: column; 74 | gap: 0.5ch; 75 | margin-top: auto; 76 | } 77 | 78 | .madeWith { 79 | align-items: center; 80 | display: inline-flex; 81 | font-weight: 500; 82 | gap: 0.5ch; 83 | } 84 | 85 | .footer { 86 | align-items: center; 87 | display: flex; 88 | font-size: 0.875rem; 89 | font-weight: 500; 90 | gap: 0.5rem; 91 | width: 100%; 92 | 93 | span { 94 | margin-right: auto; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/components/Login/Login.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { signIn } from 'next-auth/react'; 4 | import Image from 'next/image'; 5 | import Link from 'next/link'; 6 | import { FaHeart } from 'react-icons/fa'; 7 | import SunrisesWordmark from '../../../public/sunrises-wordmark.svg'; 8 | import Button from '../Button/Button'; 9 | import styles from './Login.module.scss'; 10 | 11 | const Login = () => { 12 | return ( 13 |
14 | 19 | 20 |
21 |
Aboard Logo Platzhalter
22 | 23 |
24 | Aboard ist eine alternative Oberfläche für Träwelling, dem kostenlosen 25 | Check-in Service, mit dem du deinen Freunden mitteilen kannst, wo du 26 | gerade mit öffentlichen Verkehrsmitteln unterwegs bist und Fahrtenbuch 27 | führen kannst. 28 |
29 | 30 | 33 | 34 |
35 | Hast du noch keinen Träwelling-Account? 36 |
37 | Jetzt registrieren! 38 |
39 | 40 |
41 |
42 | Made with by 43 |
44 | Sunrises Logo 45 |
46 | 47 |
48 | © 2023 Sunrises 49 | GitHub 50 | Datenschutz 51 | Impressum 52 |
53 |
54 |
55 | ); 56 | }; 57 | 58 | export default Login; 59 | -------------------------------------------------------------------------------- /src/components/NativeSelect/NativeSelect.module.scss: -------------------------------------------------------------------------------- 1 | .base { 2 | background-color: var(--slate-3); 3 | border-radius: 0.375rem; 4 | color: var(--slate-12); 5 | isolation: isolate; 6 | padding: 0.75rem; 7 | position: relative; 8 | 9 | &:hover { 10 | background-color: var(--slate-4); 11 | } 12 | 13 | label { 14 | align-items: center; 15 | cursor: pointer; 16 | display: flex; 17 | font-weight: 400; 18 | gap: 0.75rem; 19 | } 20 | 21 | select { 22 | cursor: pointer; 23 | inset: 0; 24 | opacity: 0; 25 | position: absolute; 26 | width: 100%; 27 | z-index: 99; 28 | } 29 | 30 | svg { 31 | color: var(--sky-11); 32 | color: #9E0A5B; 33 | } 34 | } 35 | 36 | .chevron { 37 | margin-left: auto; 38 | } 39 | -------------------------------------------------------------------------------- /src/components/NativeSelect/NativeSelect.tsx: -------------------------------------------------------------------------------- 1 | import { useId } from 'react'; 2 | import { TbChevronDown } from 'react-icons/tb'; 3 | import styles from './NativeSelect.module.scss'; 4 | import { NativeSelectProps } from './types'; 5 | 6 | export const NativeSelect = ({ 7 | children, 8 | onSelect, 9 | options, 10 | value, 11 | }: NativeSelectProps) => { 12 | const id = useId(); 13 | 14 | return ( 15 |
16 | 21 | 22 | 29 |
30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /src/components/NativeSelect/types.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | export type NativeSelectProps = { 4 | children: ReactNode; 5 | onSelect: (value: string) => void; 6 | options: ReactNode[]; 7 | value: string; 8 | }; 9 | -------------------------------------------------------------------------------- /src/components/Navbar/Navbar.module.scss: -------------------------------------------------------------------------------- 1 | .base { 2 | padding: 1.25rem 1rem 0.5rem; 3 | display: flex; 4 | margin-bottom: 0.75rem; 5 | gap: 1rem; 6 | justify-content: space-between; 7 | align-items: center; 8 | } 9 | 10 | .username { 11 | font-size: 1.25rem; 12 | 13 | font-weight: 600; 14 | line-height: 1; 15 | } 16 | 17 | .greeting { 18 | font-size: 0.875rem; 19 | font-weight: 400; 20 | line-height: 1; 21 | } 22 | 23 | .text { 24 | display: flex; 25 | flex-direction: column; 26 | gap: 0.25rem; 27 | } 28 | 29 | .items { 30 | display: flex; 31 | gap: 1rem; 32 | align-items: center; 33 | } 34 | -------------------------------------------------------------------------------- /src/components/Navbar/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import Notifications from '../Notifications/Notifications'; 2 | import ProfileDrawer from '../ProfileDrawer/ProfileDrawer'; 3 | import ProfileImage from '../ProfileImage/ProfileImage'; 4 | import styles from './Navbar.module.scss'; 5 | import Username from './Username'; 6 | 7 | const getCurrentGreeting = () => { 8 | const currentHour = new Date().getHours(); 9 | 10 | if (currentHour >= 5 && currentHour < 12) return 'Guten Morgen 🌞'; 11 | 12 | if (currentHour >= 12 && currentHour < 18) return 'Guten Tag 🌤️'; 13 | 14 | if (currentHour >= 18 && currentHour < 22) return 'Guten Abend 🌙'; 15 | 16 | return 'Gute Nacht 🌑'; 17 | }; 18 | 19 | const Navbar = () => { 20 | return ( 21 | 35 | ); 36 | }; 37 | 38 | export default Navbar; 39 | -------------------------------------------------------------------------------- /src/components/Navbar/Username.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useSession } from 'next-auth/react'; 4 | import { Suspense } from 'react'; 5 | import Shimmer from '../Shimmer/Shimmer'; 6 | 7 | const UsernameBase = () => { 8 | const { data: session } = useSession(); 9 | 10 | if (!session?.user.name) return null; 11 | 12 | return {session.user.name}; 13 | }; 14 | 15 | const Username = () => { 16 | return ( 17 | }> 18 | 19 | 20 | ); 21 | }; 22 | 23 | export default Username; 24 | -------------------------------------------------------------------------------- /src/components/NewLineIndicator/NewLineIndicator.module.scss: -------------------------------------------------------------------------------- 1 | .base { 2 | --line-padding: 0.375rem; 3 | 4 | align-items: center; 5 | background: var(--line-background); 6 | color: var(--line-color); 7 | display: flex; 8 | flex-shrink: 0; 9 | font-size: 0.75rem; 10 | font-weight: 600; 11 | height: 1.25rem; 12 | line-height: 1; 13 | min-width: 1.75rem; 14 | justify-content: center; 15 | padding: 0 var(--line-padding); 16 | white-space: nowrap; 17 | 18 | &[aboard-shape="hexagon"] { 19 | clip-path: polygon(0.5rem 0%, calc(100% - 0.5rem) 0%, 100% 50%, calc(100% - 0.5rem) 100%, 0.5rem 100%, 0% 50%); 20 | min-width: 2rem; 21 | padding: 0 0.625rem; 22 | } 23 | 24 | &[aboard-shape="pill"] { 25 | border-radius: 99rem; 26 | } 27 | 28 | &[aboard-shape="rectangle"] { 29 | 30 | } 31 | 32 | &[aboard-shape="smooth-rectangle"] { 33 | border-radius: 0.25rem; 34 | } 35 | 36 | &[aboard-shape="square"] { 37 | aspect-ratio: 1; 38 | border-radius: 0; 39 | min-width: unset; 40 | padding: 0; 41 | } 42 | 43 | &[aboard-shape="trapezoid"] { 44 | clip-path: polygon(0 0, 100% 0, calc(100% - 0.25rem) 100%, 0.25rem 100%); 45 | padding: 0 0.625rem; 46 | } 47 | } 48 | 49 | .hasBorder { 50 | --line-padding: 0.25rem; 51 | 52 | border: 0.125rem solid var(--line-border); 53 | } 54 | 55 | .hasOutline { 56 | outline: 1px solid #FFF; // TODO: because sometimes --contrast looks better 57 | } 58 | 59 | .wrapper { 60 | isolation: isolate; 61 | position: relative; 62 | width: fit-content; 63 | 64 | &::before { 65 | background-color: #FFF; 66 | content: ""; 67 | height: 100%; 68 | left: 0; 69 | position: absolute; 70 | transform: scale(var(--scale-x), var(--scale-y)); 71 | width: 100%; 72 | z-index: -1; 73 | } 74 | 75 | &[aboard-shape="hexagon"]::before { 76 | clip-path: polygon(calc(0.5rem * var(--scale-x)) 0%, calc(100% - (0.5rem * var(--scale-x))) 0%, 100% 50%, calc(100% - (0.5rem * var(--scale-x))) 100%, calc(0.5rem * var(--scale-x)) 100%, 0% 50%); 77 | } 78 | 79 | &[aboard-shape="trapezoid"]::before { 80 | clip-path: polygon(0 0, 100% 0, calc(100% - (0.25rem * var(--scale-x))) 100%, calc(0.25rem * var(--scale-x)) 100%); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/components/NewLineIndicator/NewLineIndicator.tsx: -------------------------------------------------------------------------------- 1 | import { inter } from '@/styles/fonts'; 2 | import clsx from 'clsx'; 3 | import { CSSProperties, useEffect, useRef } from 'react'; 4 | import styles from './NewLineIndicator.module.scss'; 5 | import { NewLineIndicatorProps } from './types'; 6 | 7 | export const NewLineIndicator = ({ 8 | line, 9 | noOutline = false, 10 | }: NewLineIndicatorProps) => { 11 | const appearanceVars = { 12 | '--line-background': line.appearance.background, 13 | '--line-border': line.appearance.border, 14 | '--line-color': line.appearance.color, 15 | }; 16 | 17 | const hasBorder = 18 | !!line.appearance.border && 19 | line.appearance.border !== line.appearance.background; 20 | 21 | const hasOutline = 22 | !noOutline && 23 | (line.appearance.accentColor === line.appearance.border || 24 | (!line.appearance.border && 25 | line.appearance.accentColor === line.appearance.background) || 26 | line.appearance.background?.startsWith('linear-gradient')); 27 | 28 | const wrapperRef = useRef(null); 29 | 30 | useEffect(() => { 31 | if (!wrapperRef.current) return; 32 | 33 | const { height, width } = wrapperRef.current.getBoundingClientRect(); 34 | 35 | wrapperRef.current.style.setProperty( 36 | '--scale-x', 37 | (Math.ceil(width + 2) / width).toString() 38 | ); 39 | wrapperRef.current.style.setProperty( 40 | '--scale-y', 41 | (Math.ceil(height + 2) / height).toString() 42 | ); 43 | }, []); 44 | 45 | if ( 46 | ['hexagon', 'trapezoid'].includes(line.appearance.shape ?? '') && 47 | hasOutline 48 | ) { 49 | return ( 50 |
56 |
60 | {line.appearance.lineName} 61 |
62 |
63 | ); 64 | } 65 | 66 | return ( 67 |
77 | {line.appearance.lineName} 78 |
79 | ); 80 | }; 81 | -------------------------------------------------------------------------------- /src/components/NewLineIndicator/types.ts: -------------------------------------------------------------------------------- 1 | import { AboardLine } from '@/types/aboard'; 2 | 3 | export type NewLineIndicatorProps = { 4 | line: AboardLine; 5 | noOutline?: boolean; 6 | }; 7 | -------------------------------------------------------------------------------- /src/components/Notifications/Notifications.module.scss: -------------------------------------------------------------------------------- 1 | .base { 2 | position: relative; 3 | display: flex; 4 | 5 | text-decoration: none; 6 | color: var(--slate-12); 7 | 8 | font-size: 1.25rem; 9 | } 10 | 11 | .dot { 12 | position: absolute; 13 | height: 0.375rem; 14 | width: 0.375rem; 15 | background-color: var(--crimson-9); 16 | border-radius: 50%; 17 | right: -0.125rem; 18 | top: -0.125rem; 19 | } 20 | -------------------------------------------------------------------------------- /src/components/Notifications/Notifications.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { useNotificationsCount } from '@/hooks/useNotifications/useNotifications'; 3 | import Link from 'next/link'; 4 | import { Suspense } from 'react'; 5 | import { MdOutlineNotifications } from 'react-icons/md'; 6 | import styles from './Notifications.module.scss'; 7 | 8 | const NotificationsBase = () => { 9 | const { amount } = useNotificationsCount(); 10 | 11 | return ( 12 | 13 | {amount !== 0 && } 14 | 15 | 16 | ); 17 | }; 18 | 19 | const Notifications = () => { 20 | return ( 21 | 24 | 25 |
26 | } 27 | > 28 | 29 | 30 | ); 31 | }; 32 | 33 | export default Notifications; 34 | -------------------------------------------------------------------------------- /src/components/Overlay/Overlay.module.scss: -------------------------------------------------------------------------------- 1 | .base { 2 | border-radius: 1rem 1rem 0 0 !important; 3 | box-shadow: #0000000D 0 0.375rem 1.5rem 0, #00000014 0 0 0 1px, #00000029 0 1px 0.25rem !important; 4 | } 5 | 6 | .backdrop { 7 | align-self: flex-start; 8 | background-image: linear-gradient(to bottom, #0000, var(--black-a9)); 9 | box-sizing: content-box; 10 | padding-bottom: 3rem; 11 | width: 100%; 12 | } 13 | 14 | .backdropContainer { 15 | background: none !important; 16 | border: none !important; 17 | display: flex; 18 | } 19 | 20 | .closeButton { 21 | all: unset; 22 | 23 | align-items: center; 24 | aspect-ratio: 1; 25 | background-color: var(--black-a6); 26 | border-radius: 50%; 27 | color: #FFF; 28 | cursor: pointer; 29 | display: flex; 30 | justify-content: center; 31 | position: absolute; 32 | right: 0.375rem; 33 | top: 0.375rem; 34 | width: 1.75rem; 35 | } 36 | 37 | .scrollContainer { 38 | background-color: #FFF; 39 | border-radius: 1rem 1rem 0 0; 40 | } 41 | 42 | .scrollWrapper { 43 | border-radius: 1rem 1rem 0 0; 44 | display: flex; 45 | flex-direction: column; 46 | flex-grow: 1; 47 | min-height: 0; 48 | overflow: hidden; 49 | position: relative; 50 | 51 | &::after, 52 | &::before { 53 | display: block; 54 | height: 3rem; 55 | left: 0; 56 | pointer-events: none; 57 | position: absolute; 58 | right: 0; 59 | transition: opacity 0.2s linear; 60 | z-index: 999999; 61 | } 62 | 63 | &::after { 64 | background-image: linear-gradient(to top, #FFF, #FFF0); 65 | border-radius: 0; 66 | bottom: 0; 67 | opacity: var(--bottom-fog-opacity, 1); 68 | } 69 | 70 | &::before { 71 | background-image: linear-gradient(to bottom, #FFF, #FFF0); 72 | border-radius: 1rem 1rem 0 0; 73 | opacity: var(--top-fog-opacity, 0); 74 | top: 0; 75 | } 76 | 77 | &.hasFog::after, 78 | &.hasFog::before { 79 | content: ''; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/components/Overlay/types.ts: -------------------------------------------------------------------------------- 1 | import { CSSProperties, ReactNode } from 'react'; 2 | 3 | export type OverlayProps = { 4 | initialSnapPosition?: number; 5 | isActive: boolean; 6 | isHidden?: boolean; 7 | onBackdropTap?: () => void; 8 | onClose?: () => void; 9 | withBackdrop?: boolean; 10 | }; 11 | 12 | export type OverlayRootProps = OverlayProps & { 13 | children: ReactNode; 14 | className?: string; 15 | style?: CSSProperties; 16 | }; 17 | -------------------------------------------------------------------------------- /src/components/ProductIcon/types.ts: -------------------------------------------------------------------------------- 1 | import { HAFASProductType } from '@/traewelling-sdk/hafasTypes'; 2 | 3 | export type ProductIconVariant = Extract< 4 | HAFASProductType, 5 | 'bus' | 'suburban' | 'subway' | 'tram' 6 | >; 7 | 8 | export type ProductIconProps = { 9 | className?: string; 10 | }; 11 | -------------------------------------------------------------------------------- /src/components/Profile/Statuses/Statuses.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useUserStatuses } from '@/hooks/useUserStatuses/useUserStatus'; 4 | 5 | const Statuses = ({ username }: { username: string }) => { 6 | const { isLoading, statuses } = useUserStatuses(username); 7 | 8 | if (isLoading) return
Loading {username} Statuses...
; 9 | 10 | return ( 11 |
12 | {statuses?.map( 13 | ({ id, username, profilePicture, body, likes, liked, train }) => { 14 | return ( 15 |
16 |
{username}
17 |
{train.lineName}
18 |
{body}
19 |
{likes}
20 |
{liked && '❤️'}
21 |
{id}
22 |
23 | ); 24 | } 25 | )} 26 |
27 | ); 28 | }; 29 | 30 | export default Statuses; 31 | -------------------------------------------------------------------------------- /src/components/ProfileDrawer/ProfileDrawer.module.scss: -------------------------------------------------------------------------------- 1 | .base { 2 | margin: 0; 3 | cursor: pointer; 4 | } 5 | 6 | .container { 7 | background-color: white; 8 | padding-bottom: env(safe-area-inset-bottom); 9 | } 10 | 11 | .headline { 12 | padding: 0 1rem; 13 | } 14 | 15 | .logout { 16 | margin-top: auto; 17 | display: flex; 18 | justify-content: space-between; 19 | align-items: center; 20 | padding: 0.5rem 1rem; 21 | border-top: 1px solid var(--black-a2); 22 | cursor: pointer; 23 | 24 | .icon { 25 | color: var(--black-12); 26 | } 27 | } 28 | 29 | .links { 30 | li { 31 | padding: 0.5rem 1rem; 32 | list-style: none; 33 | 34 | a { 35 | display: flex; 36 | gap: 0.25rem; 37 | 38 | align-items: center; 39 | } 40 | 41 | &:first-child { 42 | padding-top: 1rem; 43 | } 44 | 45 | &:last-child { 46 | padding-bottom: 1rem; 47 | } 48 | 49 | a { 50 | text-decoration: none; 51 | color: var(--black-a10); 52 | } 53 | } 54 | } 55 | 56 | .overlay { 57 | background: linear-gradient(to bottom, #0000, var(--black-a9)) !important; 58 | background: none !important; 59 | border: none !important; 60 | display: flex; 61 | } 62 | -------------------------------------------------------------------------------- /src/components/ProfileDrawer/types.ts: -------------------------------------------------------------------------------- 1 | export type ProfileDrawerProps = {} 2 | -------------------------------------------------------------------------------- /src/components/ProfileImage/ProfileImage.module.scss: -------------------------------------------------------------------------------- 1 | .base { 2 | border-radius: 50%; 3 | } 4 | 5 | .wrapper { 6 | display: flex; 7 | border-radius: 50%; 8 | } 9 | -------------------------------------------------------------------------------- /src/components/ProfileImage/ProfileImage.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useSession } from 'next-auth/react'; 4 | import Image from 'next/image'; 5 | import Shimmer from '../Shimmer/Shimmer'; 6 | import styles from './ProfileImage.module.scss'; 7 | 8 | const ProfileImage = () => { 9 | const { data: session } = useSession(); 10 | 11 | if (!session?.user.image) 12 | return ( 13 |
14 | 15 |
16 | ); 17 | 18 | return ( 19 |
20 | {session.user.name} 27 |
28 | ); 29 | }; 30 | 31 | export default ProfileImage; 32 | -------------------------------------------------------------------------------- /src/components/Providers/Providers.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { CheckInProvider } from '@/contexts/CheckIn/CheckIn.context'; 4 | import { SessionProvider } from 'next-auth/react'; 5 | import { Toaster } from 'react-hot-toast'; 6 | import type { ProvidersProps } from './types'; 7 | 8 | const Providers = ({ children, session }: ProvidersProps) => { 9 | return ( 10 | 11 | {children} 12 | 13 | 14 | ); 15 | }; 16 | 17 | export default Providers; 18 | -------------------------------------------------------------------------------- /src/components/Providers/types.ts: -------------------------------------------------------------------------------- 1 | import { Session } from 'next-auth'; 2 | import { ReactNode } from 'react'; 3 | 4 | export type ProvidersProps = { 5 | children: ReactNode; 6 | session: Session | null | undefined; 7 | }; 8 | -------------------------------------------------------------------------------- /src/components/Route/Route.module.scss: -------------------------------------------------------------------------------- 1 | @keyframes pulse { 2 | 0% { 3 | box-shadow: 4 | 0 0 0 2px rgba(var(--contrast-rgb, 255, 255, 255), 0.5), 5 | 0 0 0 2px rgba(var(--contrast-rgb, 255, 255, 255), 0.5); 6 | } 7 | 8 | 50% { 9 | box-shadow: 10 | 0 0 0 2px rgba(var(--contrast-rgb, 255, 255, 255), 0.5), 11 | 0 0 0 6px rgba(var(--contrast-rgb, 255, 255, 255), 0); 12 | } 13 | 14 | 75% { 15 | box-shadow: 16 | 0 0 0 2px rgba(var(--contrast-rgb, 255, 255, 255), 0.5), 17 | 0 0 0 0 rgba(var(--contrast-rgb, 255, 255, 255), 0); 18 | } 19 | 20 | 100% { 21 | box-shadow: 0 0 0 2px rgba(var(--contrast-rgb, 255, 255, 255), 0.5); 22 | } 23 | } 24 | 25 | .base { 26 | list-style: none; 27 | padding: 0; 28 | } 29 | 30 | .entry { 31 | column-gap: 1rem; 32 | display: grid; 33 | grid-template-columns: [decoration] 0.625rem [content] 1fr; 34 | 35 | &:not(:last-child) { 36 | --line-extension: 0.75rem; 37 | --line-offset: 0.75rem; 38 | --padding: 1rem; 39 | } 40 | 41 | .content { 42 | grid-area: content; 43 | padding-bottom: var(--padding, 0); 44 | } 45 | 46 | .decoration { 47 | display: flex; 48 | grid-area: decoration; 49 | isolation: isolate; 50 | justify-content: center; 51 | position: relative; 52 | } 53 | } 54 | 55 | .hybridLine { 56 | display: flex; 57 | flex-direction: column; 58 | height: calc(100% - var(--line-offset, 0px) + var(--line-extension, 0px)); 59 | position: absolute; 60 | top: var(--line-offset, 0); 61 | z-index: -1; 62 | 63 | .bottom { 64 | border-left: 2px dotted rgba(var(--contrast-rgb, 255, 255, 255), 0.5); 65 | flex: 1 1 40%; 66 | margin: 1.5px 0 6.5px; 67 | } 68 | 69 | .top { 70 | border-left: 2px solid var(--contrast, #fff); 71 | border-radius: 1rem; 72 | flex: 1 1 60%; 73 | } 74 | } 75 | 76 | .line { 77 | border-left: 2px solid var(--contrast, #fff); 78 | height: calc(100% - var(--line-offset, 0px) + var(--line-extension, 0px)); 79 | position: absolute; 80 | top: var(--line-offset, 0); 81 | z-index: -1; 82 | 83 | &.partial { 84 | border-left: 2px dotted rgba(var(--contrast-rgb, 255, 255, 255), 0.5); 85 | height: calc( 86 | 100% - var(--line-offset, 0px) + var(--line-extension, 0px) - 14px 87 | ); 88 | top: calc(var(--line-offset, 0px) + 7px); 89 | } 90 | 91 | &.rounded { 92 | border-radius: 1rem; 93 | } 94 | } 95 | 96 | .stopIndicator { 97 | background-color: var(--accent, var(--sky-11)); 98 | border-radius: 50%; 99 | box-shadow: 0 0 0 2px var(--contrast, #fff); 100 | height: 0.375rem; 101 | margin-top: 0.5625rem; 102 | position: absolute; 103 | top: var(--stop-offset, 0); 104 | width: 0.375rem; 105 | 106 | &.pulsating { 107 | animation: pulse 2s ease infinite; 108 | background-color: var(--contrast, #fff); 109 | box-shadow: 0 0 0 2px rgba(var(--contrast-rgb, 255, 255, 255), 0.5); 110 | } 111 | } 112 | 113 | .time { 114 | font-weight: 500; 115 | opacity: 0.85; 116 | padding-left: 0.5rem; 117 | white-space: nowrap; 118 | } 119 | -------------------------------------------------------------------------------- /src/components/Route/Route.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import styles from './Route.module.scss'; 3 | import { 4 | RouteEntryProps, 5 | RouteLineProps, 6 | RouteProps, 7 | RouteStopIndicatorProps, 8 | RouteTimeProps, 9 | } from './types'; 10 | 11 | const Route = ({ children, className }: RouteProps) => { 12 | return
    {children}
; 13 | }; 14 | 15 | const RouteEntry = ({ 16 | children, 17 | className, 18 | lineSlot, 19 | stopIndicatorVariant, 20 | }: RouteEntryProps) => { 21 | return ( 22 |
  • 23 | 27 |
    {children}
    28 |
  • 29 | ); 30 | }; 31 | 32 | const RouteLine = ({ variant = 'default' }: RouteLineProps) => { 33 | if (variant === 'hybrid') { 34 | return ( 35 |
    36 |
    37 |
    38 |
    39 | ); 40 | } 41 | 42 | return ( 43 |
    46 | ); 47 | }; 48 | 49 | const RouteStopIndicator = ({ 50 | className, 51 | variant = 'default', 52 | }: RouteStopIndicatorProps) => { 53 | return ( 54 |
    61 | ); 62 | }; 63 | 64 | const RouteTime = ({ schedule, type }: RouteTimeProps) => { 65 | return ( 66 | 67 | {type === 'arrival' ? 'an' : 'ab'} {schedule.planned} 68 | {!schedule.isOnTime && ( 69 | 70 |  +{schedule.delayInMinutes} 71 | 72 | )} 73 | 74 | ); 75 | }; 76 | 77 | Route.Entry = RouteEntry; 78 | Route.Line = RouteLine; 79 | Route.StopIndicator = RouteStopIndicator; 80 | Route.Time = RouteTime; 81 | 82 | export default Route; 83 | -------------------------------------------------------------------------------- /src/components/Route/types.ts: -------------------------------------------------------------------------------- 1 | import { Schedule } from '@/utils/parseSchedule'; 2 | import { ReactNode } from 'react'; 3 | 4 | export type RouteProps = { 5 | children: ReactNode; 6 | className?: string; 7 | }; 8 | 9 | export type RouteEntryProps = { 10 | children?: ReactNode; 11 | className?: string; 12 | lineSlot?: ReactNode; 13 | stopIndicatorVariant?: RouteStopIndicatorProps['variant']; 14 | }; 15 | 16 | export type RouteLineProps = { 17 | variant?: 'default' | 'hybrid' | 'partial'; 18 | }; 19 | 20 | export type RouteStopIndicatorProps = { 21 | className?: string; 22 | variant?: 'default' | 'pulsating'; 23 | }; 24 | 25 | export type RouteTimeProps = { 26 | schedule: Schedule; 27 | type: 'arrival' | 'departure'; 28 | }; 29 | -------------------------------------------------------------------------------- /src/components/ScrollArea/ScrollArea.module.scss: -------------------------------------------------------------------------------- 1 | .base { 2 | overflow: hidden; 3 | position: relative; 4 | --scrollbar-size: 0.625rem; 5 | 6 | &::after, 7 | &::before { 8 | display: block; 9 | height: 3rem; 10 | left: 0; 11 | pointer-events: none; 12 | position: absolute; 13 | right: 0; 14 | transition: opacity 0.2s linear; 15 | z-index: 999999; 16 | } 17 | 18 | &::after { 19 | background-image: linear-gradient(to top, #fff, #fff0); 20 | border-radius: 0 0 var(--bottom-fog-border-radius, 0) 21 | var(--bottom-fog-border-radius, 0); 22 | bottom: 0; 23 | opacity: var(--bottom-fog-opacity, 1); 24 | } 25 | 26 | &::before { 27 | background-image: linear-gradient(to bottom, #fff, #fff0); 28 | border-radius: var(--top-fog-border-radius, 0) 29 | var(--top-fog-border-radius, 0) 0 0; 30 | opacity: var(--top-fog-opacity, 0); 31 | top: 0; 32 | } 33 | 34 | &.hasFog::after, 35 | &.hasFog::before { 36 | content: ''; 37 | } 38 | } 39 | 40 | .scrollbar { 41 | // background-color: var(--black-a6); 42 | display: flex; 43 | padding: 2px; 44 | touch-action: none; 45 | transition: background-color 0.16s ease-out; 46 | user-select: none; 47 | } 48 | 49 | // .scrollbar:hover { 50 | // background-color: var(--black-a8); 51 | // } 52 | 53 | .scrollbar[data-orientation='vertical'] { 54 | width: var(--scrollbar-size); 55 | } 56 | 57 | .thumb { 58 | background-color: var(--slate-10); 59 | border-radius: var(--scrollbar-size); 60 | flex: 1; 61 | position: relative; 62 | } 63 | 64 | .thumb::before { 65 | content: ''; 66 | height: 100%; 67 | left: 50%; 68 | min-height: 44px; 69 | min-width: 44px; 70 | position: absolute; 71 | top: 50%; 72 | transform: translate3d(-50%, -50%, 0); 73 | width: 100%; 74 | } 75 | 76 | .viewport { 77 | height: 100%; 78 | overscroll-behavior: contain; 79 | width: 100%; 80 | } 81 | -------------------------------------------------------------------------------- /src/components/ScrollArea/types.ts: -------------------------------------------------------------------------------- 1 | import { CSSProperties, ReactNode } from 'react'; 2 | 3 | export type ScrollAreaProps = { 4 | bottomFogBorderRadius?: CSSProperties['borderRadius']; 5 | children: ReactNode; 6 | className?: string; 7 | noFog?: boolean; 8 | topFogBorderRadius?: CSSProperties['borderRadius']; 9 | }; 10 | -------------------------------------------------------------------------------- /src/components/Shimmer/Shimmer.module.scss: -------------------------------------------------------------------------------- 1 | @keyframes shimmer { 2 | 100% { 3 | transform: translateX(100%); 4 | } 5 | } 6 | 7 | .base { 8 | background-color: #DDDBDD; 9 | border-radius: 0.25rem; 10 | display: inline-block; 11 | height: var(--shimmer-height, 1rem); 12 | overflow: hidden; 13 | position: relative; 14 | width: var(--shimmer-width, 100%); 15 | 16 | &::after { 17 | animation: shimmer 5s infinite; 18 | background-image: linear-gradient(90deg, rgba(#FFF, 0) 0, rgba(#FFF, 0.2) 20%, rgba(#FFF, 0.5) 60%, rgba(#FFF, 0)); 19 | content: ''; 20 | inset: 0; 21 | position: absolute; 22 | transform: translateX(-100%); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/components/Shimmer/Shimmer.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { CSSProperties } from 'react'; 3 | import styles from './Shimmer.module.scss'; 4 | import { ShimmerProps } from './types'; 5 | 6 | const Shimmer = ({ className, height, style, width }: ShimmerProps) => { 7 | return ( 8 |
    18 | ); 19 | }; 20 | 21 | export default Shimmer; 22 | -------------------------------------------------------------------------------- /src/components/Shimmer/types.ts: -------------------------------------------------------------------------------- 1 | import { CSSProperties } from 'react'; 2 | 3 | export type ShimmerProps = { 4 | className?: string; 5 | height?: CSSProperties['height']; 6 | style?: CSSProperties; 7 | width?: CSSProperties['width']; 8 | }; 9 | -------------------------------------------------------------------------------- /src/components/StatusCard/types.ts: -------------------------------------------------------------------------------- 1 | import { AboardStatus } from '@/types/aboard'; 2 | 3 | export type StatusCardProps = { 4 | status: AboardStatus; 5 | }; 6 | -------------------------------------------------------------------------------- /src/components/StatusDetails/types.ts: -------------------------------------------------------------------------------- 1 | import { AboardStatus, AboardStopover, AboardTrip } from '@/types/aboard'; 2 | 3 | export type NextStopoverCountdownProps = { 4 | next: AboardStopover | undefined; 5 | setNext: (value: AboardStopover | undefined) => void; 6 | stopovers: AboardStopover[]; 7 | }; 8 | 9 | export type StatusDetailsProps = { 10 | destinationIndex?: number; 11 | originIndex?: number; 12 | status: AboardStatus; 13 | stopovers?: AboardStopover[]; 14 | trip?: AboardTrip; 15 | }; 16 | -------------------------------------------------------------------------------- /src/components/Statuses/Statuses.module.scss: -------------------------------------------------------------------------------- 1 | .base { 2 | display: flex; 3 | flex-direction: column; 4 | padding: 0 1rem; 5 | gap: 1rem; 6 | } 7 | 8 | .profilePicture { 9 | border-radius: 9999px; 10 | } 11 | 12 | .status { 13 | padding: 0.5rem 0.75rem; 14 | box-shadow: 0px 0px 2px rgba(0, 0, 0, 0.08), 0px 16px 48px rgba(0, 0, 0, 0.08); 15 | border-radius: 0.5rem; 16 | } 17 | 18 | 19 | .hr { 20 | border-color: var(--current-status-color); 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/components/Statuses/Statuses.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useDashboard } from '@/hooks/useDashboard/useDashboard'; 4 | import StatusCard from '../StatusCard/StatusCard'; 5 | import styles from './Statuses.module.scss'; 6 | 7 | const Statuses = () => { 8 | const { isLoading, statuses } = useDashboard(); 9 | 10 | if (isLoading) { 11 | return ( 12 |
    13 | 14 | 15 | 16 | 17 | 18 |
    19 | ); 20 | } 21 | 22 | if (!statuses?.length) { 23 | return
    Keine Daten vorhanden
    ; 24 | } 25 | 26 | return ( 27 |
    28 | {statuses.map((status) => ( 29 | 30 | ))} 31 |
    32 | ); 33 | }; 34 | 35 | export default Statuses; 36 | -------------------------------------------------------------------------------- /src/components/StopoverSelector/StopoverSelector.tsx: -------------------------------------------------------------------------------- 1 | import { inter } from '@/styles/fonts'; 2 | import { parseSchedule } from '@/utils/parseSchedule'; 3 | import clsx from 'clsx'; 4 | import dbCleanStationName from 'db-clean-station-name'; 5 | import { TbRouteOff } from 'react-icons/tb'; 6 | import Shimmer from '../Shimmer/Shimmer'; 7 | import { Time } from '../Time/Time'; 8 | import styles from './StopoverSelector.module.scss'; 9 | import { StopoverProps, StopoverSelectorProps } from './types'; 10 | 11 | export const StopoverSelector = ({ 12 | onSelect, 13 | stopovers, 14 | }: StopoverSelectorProps) => { 15 | return ( 16 |
      20 | {stopovers.map((stopover, index) => ( 21 |
    • 22 | onSelect(stopover)} stopover={stopover} /> 23 |
    • 24 | ))} 25 |
    26 | ); 27 | }; 28 | 29 | const StopSkeleton = () => { 30 | const width = Math.random() * (85 - 50) + 50; 31 | 32 | return ( 33 | 42 | ); 43 | }; 44 | 45 | const Stopover = ({ onClick, stopover }: StopoverProps) => { 46 | const cleanName = dbCleanStationName(stopover.station.name).trim(); 47 | 48 | const isCancelled = stopover.status === 'cancelled'; 49 | 50 | const schedule = parseSchedule({ 51 | actual: stopover.arrival.actual ?? stopover.departure.actual, 52 | planned: stopover.arrival.planned ?? stopover.departure.planned!, 53 | }); 54 | 55 | return ( 56 | 99 | ); 100 | }; 101 | -------------------------------------------------------------------------------- /src/components/StopoverSelector/types.ts: -------------------------------------------------------------------------------- 1 | import { AboardStopover } from '@/types/aboard'; 2 | 3 | export type StopoverProps = { 4 | onClick?: () => void; 5 | stopover: AboardStopover; 6 | }; 7 | 8 | export type StopoverSelectorProps = { 9 | onSelect: (value: AboardStopover) => void; 10 | stopovers: AboardStopover[]; 11 | }; 12 | -------------------------------------------------------------------------------- /src/components/ThemeProvider/ThemeProvider.module.scss: -------------------------------------------------------------------------------- 1 | .base { 2 | display: contents; 3 | } 4 | -------------------------------------------------------------------------------- /src/components/ThemeProvider/ThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | import colorConvert from 'color-convert'; 2 | import { CSSProperties } from 'react'; 3 | import styles from './ThemeProvider.module.scss'; 4 | import { ThemeProviderProps } from './types'; 5 | 6 | // TODO: improve! 7 | 8 | const ThemeProvider = ({ 9 | appearance, 10 | children, 11 | color, 12 | colorRGB, 13 | contrast, 14 | contrastRGB, 15 | invert = false, 16 | theme, 17 | }: ThemeProviderProps) => { 18 | const appearanceAccentRGB = 19 | appearance && colorConvert.hex.rgb(appearance.accentColor!).join(', '); 20 | const appearanceContrastRGB = 21 | appearance && colorConvert.hex.rgb(appearance.contrastColor!).join(', '); 22 | 23 | const style = { 24 | ['--accent']: color ?? appearance?.accentColor ?? theme?.accent, 25 | ['--accent-rgb']: colorRGB ?? appearanceAccentRGB ?? theme?.accentRGB, 26 | ['--contrast']: contrast ?? appearance?.contrastColor ?? theme?.contrast, 27 | ['--contrast-rgb']: 28 | contrastRGB ?? appearanceContrastRGB ?? theme?.contrastRGB, 29 | } as CSSProperties; 30 | 31 | const invertedStyle = { 32 | ['--accent']: contrast ?? appearance?.contrastColor ?? theme?.contrast, 33 | ['--accent-rgb']: 34 | contrastRGB ?? appearanceContrastRGB ?? theme?.contrastRGB, 35 | ['--contrast']: color ?? appearance?.accentColor ?? theme?.accent, 36 | ['--contrast-rgb']: colorRGB ?? appearanceAccentRGB ?? theme?.accentRGB, 37 | } as CSSProperties; 38 | 39 | return ( 40 |
    41 | {children} 42 |
    43 | ); 44 | }; 45 | 46 | export default ThemeProvider; 47 | -------------------------------------------------------------------------------- /src/components/ThemeProvider/types.ts: -------------------------------------------------------------------------------- 1 | import { AboardLineAppearance } from '@/types/aboard'; 2 | import { ReactNode } from 'react'; 3 | 4 | export type Theme = { 5 | accent: string; 6 | accentRGB: `${number}, ${number}, ${number}`; 7 | contrast?: string; 8 | contrastRGB?: `${number}, ${number}, ${number}`; 9 | }; 10 | 11 | export type ThemeProviderProps = { 12 | appearance?: AboardLineAppearance; 13 | children: ReactNode; 14 | color?: string; 15 | colorRGB?: string; 16 | contrast?: string; 17 | contrastRGB?: string; 18 | invert?: boolean; 19 | theme?: Theme; 20 | }; 21 | -------------------------------------------------------------------------------- /src/components/Time/Time.module.scss: -------------------------------------------------------------------------------- 1 | .base { 2 | align-items: center; 3 | display: flex; 4 | gap: 0.25rem; 5 | line-height: 1; 6 | 7 | &.vertical { 8 | flex-direction: column; 9 | font-size: 0.75rem; 10 | font-weight: 500; 11 | gap: 0; 12 | } 13 | 14 | s { 15 | color: var(--strike-text-color); 16 | position: relative; 17 | text-decoration-color: var(--strike-bottom-color, #FFF); 18 | text-decoration-thickness: 0.125rem; 19 | 20 | &::after { 21 | border-bottom: 1px solid var(--strike-top-color, currentColor); 22 | border-radius: 1rem; 23 | content: ""; 24 | display: block; 25 | left: 0; 26 | position: absolute; 27 | right: 0; 28 | top: 50%; 29 | } 30 | } 31 | 32 | time { 33 | font-variant-numeric: tabular-nums; 34 | white-space: nowrap; 35 | } 36 | } 37 | 38 | .delay { 39 | font-weight: 500; 40 | margin-left: -0.125rem; 41 | } 42 | 43 | .type { 44 | font-size: 0.75rem; 45 | font-weight: 600; 46 | } 47 | -------------------------------------------------------------------------------- /src/components/Time/Time.tsx: -------------------------------------------------------------------------------- 1 | import { radioCanada } from '@/styles/fonts'; 2 | import clsx from 'clsx'; 3 | import styles from './Time.module.scss'; 4 | import { TimeProps } from './types'; 5 | 6 | export const Time = ({ 7 | className, 8 | delayStyle, 9 | schedule, 10 | style, 11 | type, 12 | }: TimeProps) => { 13 | return ( 14 |
    23 | {delayStyle !== 'p+a' && type && ( 24 | {type === 'arrival' ? 'an' : 'ab'} 25 | )} 26 | 27 | {delayStyle === 'hidden' && } 28 | 29 | {delayStyle === 'p+a' && ( 30 | <> 31 | {!schedule.isOnTime && ( 32 | 33 | 34 | 35 | )} 36 | 37 | 38 | )} 39 | 40 | {delayStyle === 'p+d' && ( 41 | <> 42 | 43 | {!schedule.isOnTime && ( 44 | 45 | {schedule.isDelayed && '+'} 46 | {schedule.delayInMinutes} 47 | 48 | )} 49 | 50 | )} 51 |
    52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /src/components/Time/types.ts: -------------------------------------------------------------------------------- 1 | import { Schedule } from '@/utils/parseSchedule'; 2 | import { CSSProperties } from 'react'; 3 | 4 | export type TimeProps = { 5 | className?: string; 6 | delayStyle: 'hidden' | 'p+a' | 'p+d'; 7 | schedule: Schedule; 8 | style?: CSSProperties; 9 | type?: 'arrival' | 'departure'; 10 | }; 11 | -------------------------------------------------------------------------------- /src/components/TripSelector/types.ts: -------------------------------------------------------------------------------- 1 | import { AboardStation, AboardTrip } from '@/types/aboard'; 2 | 3 | export type TripProps = { 4 | onClick?: () => void; 5 | requestedStationName?: string; 6 | trip: AboardTrip; 7 | }; 8 | 9 | export type TripSelectorProps = { 10 | onSelect: (value: AboardTrip) => void; 11 | requestedStation?: AboardStation; 12 | trips: AboardTrip[]; 13 | }; 14 | -------------------------------------------------------------------------------- /src/contexts/CheckIn/CheckIn.context.tsx: -------------------------------------------------------------------------------- 1 | import { useSession } from 'next-auth/react'; 2 | import { PropsWithChildren, createContext, useReducer } from 'react'; 3 | import { checkInReducer, initialCheckInState } from './reducer'; 4 | import { CheckInContextValue } from './types'; 5 | 6 | const initialState = initialCheckInState(); 7 | 8 | export const CheckInContext = createContext({ 9 | // currentStatus: undefined, 10 | // departureTime: undefined, 11 | // destination: undefined, 12 | // message: '', 13 | // origin: undefined, 14 | // setDepartureTime: () => void 0, 15 | // setDestination: () => void 0, 16 | // setMessage: () => void 0, 17 | // setTravelType: () => void 0, 18 | // setTrip: () => void 0, 19 | // setVisibility: () => void 0, 20 | // travelType: 0, 21 | // trip: undefined, 22 | // visibility: 0, 23 | 24 | confirm: () => void 0, 25 | join: () => void 0, 26 | perform: () => void 0, 27 | reportFailure: () => void 0, 28 | reportSuccess: () => void 0, 29 | reset: () => void 0, 30 | selectDestination: () => void 0, 31 | selectOrigin: () => void 0, 32 | selectTrip: () => void 0, 33 | state: initialState, 34 | }); 35 | 36 | export const CheckInProvider = ({ children }: PropsWithChildren) => { 37 | const { data: session } = useSession(); 38 | 39 | const [state, dispatch] = useReducer(checkInReducer, initialState); 40 | 41 | const contextValue: CheckInContextValue = { 42 | confirm: (params) => dispatch({ ...params, type: 'confirm_check_in' }), 43 | join: (params) => dispatch({ ...params, type: 'join_check_in' }), 44 | perform: () => dispatch({ type: 'perform_check_in' }), 45 | reportFailure: (params) => dispatch({ ...params, type: 'report_failure' }), 46 | reportSuccess: (params) => dispatch({ ...params, type: 'report_success' }), 47 | reset: () => dispatch({ type: 'reset' }), 48 | selectDestination: (params) => 49 | dispatch({ ...params, type: 'select_destination' }), 50 | selectOrigin: (params) => dispatch({ ...params, type: 'select_origin' }), 51 | selectTrip: (params) => dispatch({ ...params, type: 'select_trip' }), 52 | state, 53 | }; 54 | 55 | return ( 56 | 57 | {children} 58 | 59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /src/contexts/CheckIn/reducer.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable indent */ 2 | import { transformAboardTravelReason } from '@/traewelling-sdk/transformers'; 3 | import { CheckInAction, CheckInState } from './types'; 4 | 5 | export const initialCheckInState = (): CheckInState => ({ 6 | departureTime: undefined, 7 | destination: undefined, 8 | hafasId: undefined, 9 | message: '', 10 | origin: undefined, 11 | status: 'draft', 12 | travelReason: 0, 13 | trip: undefined, 14 | visibility: 0, 15 | }); 16 | 17 | export const checkInReducer = ( 18 | state: CheckInState, 19 | action: CheckInAction 20 | ): CheckInState => { 21 | switch (action.type) { 22 | case 'confirm_check_in': 23 | return { 24 | ...state, 25 | message: action.message, 26 | status: 'ready', 27 | travelReason: action.travelType, 28 | visibility: action.visibility, 29 | }; 30 | case 'join_check_in': 31 | return { 32 | ...initialCheckInState(), 33 | departureTime: action.status.journey.origin.departure.planned, 34 | hafasId: action.status.journey.hafasTripId, 35 | message: action.status.message, 36 | origin: action.status.journey.origin.station, 37 | travelReason: transformAboardTravelReason(action.status.travelReason), 38 | trip: action.trip, 39 | }; 40 | case 'perform_check_in': 41 | return { ...state, status: 'loading' }; 42 | case 'report_failure': 43 | return { 44 | ...state, 45 | status: 'draft', 46 | message: JSON.stringify(action.error), 47 | }; 48 | case 'report_success': 49 | return { ...state, status: 'completed' }; 50 | case 'reset': 51 | return initialCheckInState(); 52 | case 'select_departure_time': 53 | return { ...state, departureTime: action.departureTime }; 54 | case 'select_destination': 55 | return { ...state, destination: action.destination }; 56 | case 'select_origin': 57 | return { ...initialCheckInState(), origin: action.origin }; 58 | case 'select_trip': 59 | return { 60 | ...state, 61 | destination: undefined, 62 | hafasId: action.trip.hafasId, 63 | trip: action.trip, 64 | }; 65 | } 66 | }; 67 | -------------------------------------------------------------------------------- /src/contexts/CheckIn/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AboardStation, 3 | AboardStatus, 4 | AboardStopover, 5 | AboardTrip, 6 | } from '@/types/aboard'; 7 | 8 | type CheckInStatus = 'draft' | 'ready' | 'loading' | 'completed'; 9 | 10 | export type CheckInContextValue = { 11 | // currentStatus: Status | null | undefined; 12 | // departureTime: string | undefined; 13 | // destination: Stop | undefined; 14 | // message: string; 15 | // origin: StationIdentity | undefined; 16 | // setDepartureTime: (value: string | undefined) => void; 17 | // setDestination: (value: Stop | undefined) => void; 18 | // setMessage: (value: string) => void; 19 | // setTravelType: (value: number) => void; 20 | // setTrip: (value: HAFASTrip | undefined) => void; 21 | // setVisibility: (value: number) => void; 22 | // travelType: number; 23 | // trip: HAFASTrip | undefined; 24 | // visibility: number; 25 | 26 | confirm: (params: Omit) => void; 27 | join: (params: Omit) => void; 28 | perform: () => void; 29 | reportFailure: (params: Omit) => void; 30 | reportSuccess: (params: Omit) => void; 31 | reset: () => void; 32 | selectDestination: (params: Omit) => void; 33 | selectOrigin: (params: Omit) => void; 34 | selectTrip: (params: Omit) => void; 35 | state: CheckInState; 36 | }; 37 | 38 | export type CheckInState = { 39 | departureTime: string | undefined; 40 | destination: AboardStopover | undefined; 41 | hafasId: string | undefined; 42 | message: string; 43 | origin: AboardStation | undefined; 44 | status: CheckInStatus; 45 | travelReason: number; 46 | trip: AboardTrip | undefined; 47 | visibility: number; 48 | }; 49 | 50 | type ConfirmCheckInAction = { 51 | type: 'confirm_check_in'; 52 | 53 | message: string; 54 | travelType: number; 55 | visibility: number; 56 | }; 57 | 58 | type JoinCheckInAction = { 59 | type: 'join_check_in'; 60 | 61 | status: AboardStatus; 62 | trip?: AboardTrip; 63 | }; 64 | 65 | type PerformCheckInAction = { 66 | type: 'perform_check_in'; 67 | }; 68 | 69 | type ReportFailureAction = { 70 | type: 'report_failure'; 71 | 72 | // TODO 73 | error?: any; 74 | }; 75 | 76 | type ReportSuccessAction = { 77 | type: 'report_success'; 78 | 79 | // TODO 80 | data?: any; 81 | }; 82 | 83 | type ResetAction = { 84 | type: 'reset'; 85 | }; 86 | 87 | type SelectDepartureTimeAction = { 88 | type: 'select_departure_time'; 89 | 90 | departureTime: string | undefined; 91 | }; 92 | 93 | type SelectDestinationAction = { 94 | type: 'select_destination'; 95 | 96 | destination: AboardStopover; 97 | }; 98 | 99 | type SelectOriginAction = { 100 | type: 'select_origin'; 101 | 102 | origin: AboardStation; 103 | }; 104 | 105 | type SelectTripAction = { 106 | type: 'select_trip'; 107 | 108 | trip: AboardTrip; 109 | }; 110 | 111 | export type CheckInAction = 112 | | ConfirmCheckInAction 113 | | JoinCheckInAction 114 | | PerformCheckInAction 115 | | ReportFailureAction 116 | | ReportSuccessAction 117 | | ResetAction 118 | | SelectDepartureTimeAction 119 | | SelectDestinationAction 120 | | SelectOriginAction 121 | | SelectTripAction; 122 | -------------------------------------------------------------------------------- /src/helpers/getContrastColor.ts: -------------------------------------------------------------------------------- 1 | export const getContrastColor = (r: number, g: number, b: number) => { 2 | return r * 0.299 + g * 0.587 + b * 0.114 > 150 ? '#000000' : '#FFFFFF'; 3 | }; 4 | -------------------------------------------------------------------------------- /src/helpers/getLineTheme/consts.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from '@/components/ThemeProvider/types'; 2 | import { HAFASProductType } from '@/traewelling-sdk/hafasTypes'; 3 | 4 | export const CUSTOM_LINE_THEMES: Record = { 5 | // Hannover, S-Bahn 6 | '4-tdhs-1': { accent: '#836CAA', accentRGB: '131, 108, 170' }, 7 | '4-tdhs-2': { accent: '#007A3C', accentRGB: '0, 122, 60' }, 8 | '4-tdhs-3': { accent: '#CB68A6', accentRGB: '203, 104, 166' }, 9 | '4-tdhs-4': { accent: '#9A2A47', accentRGB: '154, 42, 71' }, 10 | '4-tdhs-5': { accent: '#F18700', accentRGB: '241, 135, 0' }, 11 | '4-tdhs-6': { accent: '#004F9E', accentRGB: '0, 79, 158' }, 12 | '4-tdhs-7': { accent: '#AFCA26', accentRGB: '175, 202, 38' }, 13 | '4-tdhs-8': { accent: '#009AD9', accentRGB: '0, 154, 217' }, 14 | '4-tdhs-21': { accent: '#007A3C', accentRGB: '0, 122, 60' }, 15 | '4-tdhs-51': { accent: '#F18700', accentRGB: '241, 135, 0' }, 16 | // Hannover, Stadtbahn 17 | '8-webuet-1': { accent: '#FF2E3E', accentRGB: '255, 46, 62' }, 18 | '8-webuet-2': { accent: '#FF2E3E', accentRGB: '255, 46, 62' }, 19 | '8-webuet-3': { accent: '#0073C0', accentRGB: '0, 115, 192' }, 20 | '8-webuet-4': { accent: '#FFAC2E', accentRGB: '255, 172, 46' }, 21 | '8-webuet-5': { accent: '#FFAC2E', accentRGB: '255, 172, 46' }, 22 | '8-webuet-6': { accent: '#FFAC2E', accentRGB: '255, 172, 46' }, 23 | '8-webuet-7': { accent: '#0073C0', accentRGB: '0, 115, 192' }, 24 | '8-webuet-8': { accent: '#FF2E3E', accentRGB: '255, 46, 62' }, 25 | '8-webuet-9': { accent: '#0073C0', accentRGB: '0, 115, 192' }, 26 | '8-webuet-10': { accent: '#6DC248', accentRGB: '109, 194, 72' }, 27 | '8-webuet-11': { accent: '#FFAC2E', accentRGB: '255, 172, 46' }, 28 | '8-webuet-17': { accent: '#6DC248', accentRGB: '109, 194, 72' }, 29 | }; 30 | 31 | export const PRODUCT_THEMES: Record> = { 32 | bus: { 33 | accent: '#A3007C', 34 | accentRGB: '163, 0, 124', 35 | contrast: '#FFFFFF', 36 | contrastRGB: '255, 255, 255', 37 | }, 38 | ferry: { 39 | accent: '#284B63', 40 | accentRGB: '40, 75, 99', 41 | contrast: '#FFFFFF', 42 | contrastRGB: '255, 255, 255', 43 | }, 44 | national: { 45 | accent: '#2B2D42', 46 | accentRGB: '43, 45, 66', 47 | contrast: '#FFFFFF', 48 | contrastRGB: '255, 255, 255', 49 | }, 50 | nationalExpress: { 51 | accent: '#2B2D42', 52 | accentRGB: '43, 45, 66', 53 | contrast: '#FFFFFF', 54 | contrastRGB: '255, 255, 255', 55 | }, 56 | regional: { 57 | accent: '#415A77', 58 | accentRGB: '65, 90, 119', 59 | contrast: '#FFFFFF', 60 | contrastRGB: '255, 255, 255', 61 | }, 62 | regionalExp: { 63 | accent: '#415A77', 64 | accentRGB: '65, 90, 119', 65 | contrast: '#FFFFFF', 66 | contrastRGB: '255, 255, 255', 67 | }, 68 | suburban: { 69 | accent: '#006F35', 70 | accentRGB: '0, 111, 53', 71 | contrast: '#FFFFFF', 72 | contrastRGB: '255, 255, 255', 73 | }, 74 | subway: { 75 | accent: '#0065AE', 76 | accentRGB: '0, 101, 174', 77 | contrast: '#FFFFFF', 78 | contrastRGB: '255, 255, 255', 79 | }, 80 | taxi: { 81 | accent: '#AF8000', 82 | accentRGB: '175, 128, 0', 83 | contrast: '#FFFFFF', 84 | contrastRGB: '255, 255, 255', 85 | }, 86 | tram: { 87 | accent: '#D91A1A', 88 | accentRGB: '217, 26, 26', 89 | contrast: '#FFFFFF', 90 | contrastRGB: '255, 255, 255', 91 | }, 92 | }; 93 | -------------------------------------------------------------------------------- /src/helpers/getLineTheme/getLineTheme.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from '@/components/ThemeProvider/types'; 2 | import { HAFASProductType } from '@/traewelling-sdk/hafasTypes'; 3 | import { getContrastColor } from '../getContrastColor'; 4 | import { CUSTOM_LINE_THEMES, PRODUCT_THEMES } from './consts'; 5 | 6 | export const getLineTheme = ( 7 | id: string, 8 | productType: HAFASProductType 9 | ): Required => { 10 | const theme = CUSTOM_LINE_THEMES[id]; 11 | 12 | if (!theme) { 13 | return PRODUCT_THEMES[productType]; 14 | } 15 | 16 | const values = theme.accentRGB.split(',').map((v) => +v.trim()); 17 | const contrast = getContrastColor(values[0], values[1], values[2]); 18 | 19 | return { 20 | contrast, 21 | contrastRGB: contrast === '#000000' ? '0, 0, 0' : '255, 255, 255', 22 | ...theme, 23 | }; 24 | }; 25 | 26 | export const getWhiteLineTheme = ( 27 | id: string, 28 | productType: HAFASProductType 29 | ): Required => { 30 | const theme = CUSTOM_LINE_THEMES[id]; 31 | 32 | if (!theme) { 33 | const { accent, accentRGB } = PRODUCT_THEMES[productType]; 34 | return { 35 | accent: '#FFF', 36 | accentRGB: '255, 255, 255', 37 | contrast: accent, 38 | contrastRGB: accentRGB, 39 | }; 40 | } 41 | 42 | const { accent, accentRGB } = theme; 43 | 44 | return { 45 | accent: '#FFF', 46 | accentRGB: '255, 255, 255', 47 | contrast: accent, 48 | contrastRGB: accentRGB, 49 | }; 50 | }; 51 | -------------------------------------------------------------------------------- /src/helpers/getStopsAfter.ts: -------------------------------------------------------------------------------- 1 | import { Stop } from '@/traewelling-sdk/types'; 2 | 3 | export const getStopsAfter = ( 4 | plannedDeparture: string, 5 | stationId: string, 6 | stops: Stop[] 7 | ) => { 8 | const after = new Date(plannedDeparture).toISOString(); 9 | 10 | const startingAt = stops.findIndex( 11 | ({ departurePlanned, id }) => 12 | after === new Date(departurePlanned!).toISOString() && 13 | stationId === id.toString() 14 | ); 15 | 16 | return stops.slice(typeof startingAt === 'undefined' ? 0 : startingAt + 1); 17 | }; 18 | -------------------------------------------------------------------------------- /src/helpers/identifyLineByMagic/index.ts: -------------------------------------------------------------------------------- 1 | import { AboardLine } from '@/types/aboard'; 2 | import { WELL_KNOWN_LINES } from './conts'; 3 | 4 | export const identifyLineByMagic = ( 5 | hafasTripId: string | undefined, 6 | line: AboardLine 7 | ): AboardLine => { 8 | if (!hafasTripId) return line; 9 | 10 | const fr = hafasTripId.match(/#FR#(\d+)#/)?.[1]; 11 | const to = hafasTripId.match(/#TO#(\d+)#/)?.[1]; 12 | 13 | if (!fr || !to) return line; 14 | 15 | const wellKnownLine = WELL_KNOWN_LINES.find( 16 | (l) => 17 | l.name === line.name && 18 | l.stations.includes(+fr) && 19 | l.stations.includes(+to) 20 | ); 21 | 22 | if (!wellKnownLine) return line; 23 | 24 | return { 25 | ...line, 26 | id: wellKnownLine.id, 27 | operator: wellKnownLine.operator 28 | ? { id: wellKnownLine.operator, name: '' } 29 | : undefined, 30 | }; 31 | }; 32 | -------------------------------------------------------------------------------- /src/helpers/lineAppearance/consts.ts: -------------------------------------------------------------------------------- 1 | import { AboardLineAppearance, AboardMethod } from '@/types/aboard'; 2 | 3 | export const FALLBACK_METHOD_APPEARANCES: Record< 4 | AboardMethod, 5 | Partial 6 | > = { 7 | bus: { 8 | accentColor: '#A3007C', 9 | background: '#A3007C', 10 | color: '#FFFFFF', 11 | contrastColor: '#FFFFFF', 12 | shape: 'pill', 13 | }, 14 | ferry: { 15 | accentColor: '#284B63', 16 | background: '#284B63', 17 | color: '#FFFFFF', 18 | contrastColor: '#FFFFFF', 19 | shape: 'trapezoid', 20 | }, 21 | national: { 22 | accentColor: '#2B2D42', 23 | background: '#2B2D42', 24 | color: '#FFFFFF', 25 | contrastColor: '#FFFFFF', 26 | shape: 'smooth-rectangle', 27 | }, 28 | 'national-express': { 29 | accentColor: '#2B2D42', 30 | background: '#2B2D42', 31 | color: '#FFFFFF', 32 | contrastColor: '#FFFFFF', 33 | shape: 'smooth-rectangle', 34 | }, 35 | regional: { 36 | accentColor: '#415A77', 37 | background: '#415A77', 38 | color: '#FFFFFF', 39 | contrastColor: '#FFFFFF', 40 | shape: 'smooth-rectangle', 41 | }, 42 | 'regional-express': { 43 | accentColor: '#415A77', 44 | background: '#415A77', 45 | color: '#FFFFFF', 46 | contrastColor: '#FFFFFF', 47 | shape: 'smooth-rectangle', 48 | }, 49 | suburban: { 50 | accentColor: '#006F35', 51 | background: '#006F35', 52 | color: '#FFFFFF', 53 | contrastColor: '#FFFFFF', 54 | shape: 'smooth-rectangle', 55 | }, 56 | subway: { 57 | accentColor: '#0065AE', 58 | background: '#0065AE', 59 | color: '#FFFFFF', 60 | contrastColor: '#FFFFFF', 61 | shape: 'rectangle', 62 | }, 63 | taxi: { 64 | accentColor: '#AF8000', 65 | background: '#AF8000', 66 | color: '#FFFFFF', 67 | contrastColor: '#FFFFFF', 68 | shape: 'smooth-rectangle', 69 | }, 70 | tram: { 71 | accentColor: '#D91A1A', 72 | background: '#D91A1A', 73 | color: '#FFFFFF', 74 | contrastColor: '#FFFFFF', 75 | shape: 'pill', 76 | }, 77 | }; 78 | 79 | export const LINE_APPEARANCE_OVERRIDES: [ 80 | RegExp, 81 | Partial, 82 | ][] = [ 83 | // [ 84 | // /^5-hvv[a-z]{3}-\d+$/, // All bus lines operated in Hamburg 85 | // { accentColor: '#E2001A', background: '#E2001A', shape: 'hexagon' }, 86 | // ], 87 | // [ 88 | // /^6-hvvhad-\d+$/, // All ferries operated by HADAG in Hamburg 89 | // { 90 | // accentColor: '#00B7E1', 91 | // background: '#00B7E1', 92 | // color: '#FFFFFF', 93 | // contrastColor: '#000000', 94 | // }, 95 | // ], 96 | // [ 97 | // /^7-swm001-7$/, // U7 operated by SWM in München 98 | // { 99 | // accentColor: '#C3022D', 100 | // background: 'linear-gradient(to bottom right, #51832B 50%, #C3022D 50%)', 101 | // }, 102 | // ], 103 | // [ 104 | // /^7-swm001-8$/, // U8 operated by SWM in München 105 | // { 106 | // accentColor: '#C3022D', 107 | // background: 'linear-gradient(to bottom right, #C3022D 50%, #ED6720 50%)', 108 | // }, 109 | // ], 110 | [/^8-webuet-\d{1,2}$/, { shape: 'square' }], // All tram lines operated by ÜSTRA in Hannover 111 | ]; 112 | -------------------------------------------------------------------------------- /src/helpers/lineAppearance/fetcher.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { TrwlLineColorDefinition } from '@/traewelling-sdk/types'; 4 | import Papa from 'papaparse'; 5 | 6 | export const fetchTrwlLineColorDefinitions = async () => { 7 | const url = new URL( 8 | 'https://raw.githubusercontent.com/Traewelling/line-colors/main/line-colors.csv' 9 | ); 10 | 11 | const res = await fetch(url, { 12 | method: 'GET', 13 | next: { 14 | revalidate: 60 * 60 * 24 * 7, // 1 week 15 | tags: ['trwl-line-colors'], 16 | }, 17 | }); 18 | 19 | const data = await res.text(); 20 | 21 | if (res.status === 200) { 22 | const result = Papa.parse(data, { 23 | header: true, 24 | skipEmptyLines: true, 25 | }); 26 | 27 | return result.data; 28 | } 29 | 30 | throw { message: data, status: res.status }; 31 | }; 32 | -------------------------------------------------------------------------------- /src/helpers/lineAppearance/index.ts: -------------------------------------------------------------------------------- 1 | import { transformTrwlLineShape } from '@/traewelling-sdk/transformers'; 2 | import { TrwlLineColorDefinition } from '@/traewelling-sdk/types'; 3 | import { AboardLine, AboardLineAppearance } from '@/types/aboard'; 4 | import colorConvert from 'color-convert'; 5 | import { getContrastColor } from '../getContrastColor'; 6 | import { 7 | FALLBACK_METHOD_APPEARANCES, 8 | LINE_APPEARANCE_OVERRIDES, 9 | } from './consts'; 10 | import { fetchTrwlLineColorDefinitions } from './fetcher'; 11 | 12 | export const createLineAppearanceDataset = async () => { 13 | const trwlLineColorDefinitions = await fetchTrwlLineColorDefinitions(); 14 | const dataset = trwlLineColorDefinitions.map((definition) => { 15 | const overrides = LINE_APPEARANCE_OVERRIDES.filter(([pattern]) => 16 | pattern.test(definition.hafasLineId) 17 | ).map(([, override]) => override); 18 | 19 | const accentColor = determineAccentColor(definition); 20 | const [accentR, accentG, accentB] = colorConvert.hex.rgb(accentColor); 21 | 22 | const appearance: Partial = Object.assign( 23 | { 24 | accentColor, 25 | background: definition.backgroundColor, 26 | border: definition.borderColor, 27 | color: definition.textColor, 28 | contrastColor: getContrastColor(accentR, accentG, accentB), 29 | lineName: definition.lineName, 30 | shape: transformTrwlLineShape(definition.shape), 31 | } satisfies Partial, 32 | ...overrides 33 | ); 34 | 35 | return { 36 | appearance, 37 | lineId: definition.hafasLineId, 38 | operatorId: definition.hafasOperatorCode, 39 | }; 40 | }); 41 | 42 | return { 43 | getAppearanceForLine: (line: AboardLine): AboardLineAppearance => { 44 | const fromDataset = dataset.find( 45 | ({ lineId, operatorId }) => 46 | lineId === line.id && 47 | (operatorId === '' || operatorId === line.operator?.id) 48 | ); 49 | 50 | if (fromDataset) { 51 | return Object.assign(line.appearance, fromDataset.appearance); 52 | } 53 | 54 | const overrides = LINE_APPEARANCE_OVERRIDES.filter(([pattern]) => 55 | pattern.test(line.id) 56 | ).map(([, override]) => override); 57 | 58 | return Object.assign( 59 | line.appearance, 60 | FALLBACK_METHOD_APPEARANCES[line.method], 61 | ...overrides 62 | ); 63 | }, 64 | }; 65 | }; 66 | 67 | const determineAccentColor = (definition: TrwlLineColorDefinition) => { 68 | // Preparations for background gradients 69 | // const backgroundColors = 70 | // definition.backgroundColor.toLowerCase().match(/(#[a-f\d]{3,6})/gi) ?? []; 71 | 72 | const palette = [ 73 | // ...backgroundColors, 74 | definition.backgroundColor.toLowerCase(), 75 | definition.borderColor.toLowerCase(), 76 | definition.textColor.toLowerCase(), 77 | ] 78 | .filter((color) => !!color && color !== '#fff' && color !== '#ffffff') 79 | .sort((a, b) => { 80 | const [, aS, aL] = colorConvert.hex.hsl(a); 81 | const [, bS, bL] = colorConvert.hex.hsl(b); 82 | 83 | return 2 * bS * bL - 2 * aS * aL; 84 | }); 85 | 86 | return palette[0]; 87 | }; 88 | -------------------------------------------------------------------------------- /src/hooks/useAppTheme/useAppTheme.ts: -------------------------------------------------------------------------------- 1 | import { CSSProperties, useLayoutEffect } from 'react'; 2 | 3 | const useAppTheme = (accent: CSSProperties['color']) => { 4 | useLayoutEffect(() => { 5 | document.body.style.setProperty('--app-theme', accent ?? ''); 6 | 7 | return () => { 8 | document.body.style.removeProperty('--app-theme'); 9 | }; 10 | }); 11 | }; 12 | 13 | export default useAppTheme; 14 | -------------------------------------------------------------------------------- /src/hooks/useCheckIn/useCheckIn.ts: -------------------------------------------------------------------------------- 1 | import { CheckInContext } from '@/contexts/CheckIn/CheckIn.context'; 2 | import { useContext } from 'react'; 3 | 4 | export const useCheckIn = () => { 5 | return useContext(CheckInContext); 6 | }; 7 | -------------------------------------------------------------------------------- /src/hooks/useConsecutiveOverlays/types.ts: -------------------------------------------------------------------------------- 1 | import { OverlayProps } from '@/components/Overlay/types'; 2 | 3 | export type ConsecutiveOverlays = { 4 | [O in `${T}Props`]: OverlayProps; 5 | }; 6 | -------------------------------------------------------------------------------- /src/hooks/useConsecutiveOverlays/useConsecutiveOverlays.ts: -------------------------------------------------------------------------------- 1 | import { OverlayProps } from '@/components/Overlay/types'; 2 | import { useState } from 'react'; 3 | import { ConsecutiveOverlays } from './types'; 4 | 5 | export const useConsecutiveOverlays = ( 6 | order: readonly T[] 7 | ) => { 8 | if (!order.length) throw TypeError('`order` contains no elements'); 9 | 10 | const [isActive, setActive] = useState(false); 11 | const [current, setCurrent] = useState(order[0]); 12 | const [last, setLast] = useState(); 13 | 14 | const currentIndex = order.indexOf(current); 15 | 16 | const next = () => { 17 | if (currentIndex === order.length - 1) { 18 | return; 19 | } 20 | 21 | setLast(current); 22 | setCurrent(order[currentIndex + 1]); 23 | setTimeout(() => setLast(undefined), 200); 24 | }; 25 | 26 | const previous = () => { 27 | if (currentIndex === 0) { 28 | return; 29 | } 30 | 31 | setLast(current); 32 | setCurrent(order[currentIndex - 1]); 33 | setTimeout(() => setLast(undefined), 200); 34 | }; 35 | 36 | return { 37 | ...(order.reduce((props, overlay, overlayIndex) => { 38 | return { 39 | ...props, 40 | [`${overlay}Props`]: { 41 | isActive: isActive && overlayIndex <= currentIndex, 42 | isHidden: currentIndex > overlayIndex && last !== overlay, 43 | onClose: overlayIndex === 0 ? undefined : previous, 44 | withBackdrop: currentIndex <= overlayIndex, 45 | } satisfies OverlayProps, 46 | }; 47 | }, {}) as ConsecutiveOverlays), 48 | next, 49 | setActive, 50 | }; 51 | }; 52 | -------------------------------------------------------------------------------- /src/hooks/useCurrentStatus/useCurrentStatus.ts: -------------------------------------------------------------------------------- 1 | import { AboardCurrentStatusResponse } from '@/app/api/statuses/current/route'; 2 | import useSWR from 'swr'; 3 | 4 | const fetcher = async (): Promise => { 5 | const response = await fetch('/api/statuses/current'); 6 | 7 | if (!response.ok) { 8 | return null; 9 | } 10 | 11 | return await response.json(); 12 | }; 13 | 14 | export const useCurrentStatus = () => { 15 | const { data, isLoading, mutate } = useSWR(['/api/statuses/current'], () => 16 | fetcher() 17 | ); 18 | 19 | return { 20 | isLoading, 21 | mutate, 22 | status: data, 23 | }; 24 | }; 25 | -------------------------------------------------------------------------------- /src/hooks/useDashboard/useDashboard.ts: -------------------------------------------------------------------------------- 1 | import { AboardDashboardResponse } from '@/app/api/dashboard/route'; 2 | import useSWR from 'swr'; 3 | 4 | const fetcher = async (): Promise => { 5 | const response = await fetch('/api/dashboard'); 6 | 7 | if (!response.ok) { 8 | return null; 9 | } 10 | 11 | return await response.json(); 12 | }; 13 | 14 | export const useDashboard = () => { 15 | const { data, isLoading } = useSWR(['/api/dashboard'], ([_]) => fetcher()); 16 | 17 | return { 18 | isLoading, 19 | statuses: data, 20 | }; 21 | }; 22 | -------------------------------------------------------------------------------- /src/hooks/useDepartures/types.ts: -------------------------------------------------------------------------------- 1 | import { TransportType } from '@/traewelling-sdk/types'; 2 | 3 | export type UseDeparturesOptions = { 4 | from?: string; 5 | transportType?: TransportType; 6 | }; 7 | -------------------------------------------------------------------------------- /src/hooks/useDepartures/useDepartures.ts: -------------------------------------------------------------------------------- 1 | import { AboardDeparturesResponse } from '@/app/api/stations/[station]/route'; 2 | import { TransportType } from '@/traewelling-sdk/types'; 3 | import useSWR from 'swr'; 4 | import { UseDeparturesOptions } from './types'; 5 | 6 | const fetcher = async ( 7 | stationId?: number, 8 | transportType?: TransportType, 9 | from?: string 10 | ): Promise => { 11 | if (typeof stationId === 'undefined') { 12 | return { meta: null, trips: [] }; 13 | } 14 | 15 | const params = new URLSearchParams(); 16 | 17 | if (!!transportType) { 18 | params.append('transportType', transportType); 19 | } 20 | 21 | if (!!from) { 22 | params.append('from', from); 23 | } 24 | 25 | const response = await fetch( 26 | `/api/stations/${stationId}?${params.toString()}` 27 | ); 28 | 29 | if (!response.ok) { 30 | return { meta: null, trips: [] }; 31 | } 32 | 33 | return await response.json(); 34 | }; 35 | 36 | export const useDepartures = ( 37 | stationId?: number, 38 | options?: UseDeparturesOptions 39 | ) => { 40 | const { data, isLoading } = useSWR( 41 | ['/api/stations/', stationId, options?.transportType, options?.from], 42 | ([_, stationId, transportType, from]) => 43 | fetcher(stationId, transportType, from) 44 | ); 45 | 46 | return { 47 | departures: data, 48 | isLoading, 49 | }; 50 | }; 51 | -------------------------------------------------------------------------------- /src/hooks/useIsDesktop/useIsDesktop.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | export const useIsDesktop = () => { 4 | const [isDesktop, setIsDesktop] = useState(false); 5 | 6 | useEffect(() => { 7 | if (typeof window !== 'undefined') { 8 | const resizeListener = () => { 9 | setIsDesktop(window.innerWidth >= 768); 10 | }; 11 | resizeListener(); 12 | 13 | window.addEventListener('resize', resizeListener, { passive: true }); 14 | return () => { 15 | window.removeEventListener('resize', resizeListener); 16 | }; 17 | } 18 | }, []); 19 | 20 | return isDesktop; 21 | }; 22 | -------------------------------------------------------------------------------- /src/hooks/useLockBodyScroll/useLockBodyScroll.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | const useLockBodyScroll = () => { 4 | useEffect(() => { 5 | // Get original body overflow 6 | const originalStyle = window.getComputedStyle(document.body).overflow; 7 | // Prevent scrolling on mount 8 | document.body.style.overflow = 'hidden'; 9 | // Re-enable scrolling when component unmounts 10 | return () => { 11 | document.body.style.overflow = originalStyle; 12 | }; 13 | }, []); // Empty array ensures effect is only run on mount and unmount 14 | }; 15 | 16 | export default useLockBodyScroll; 17 | -------------------------------------------------------------------------------- /src/hooks/useNotifications/useNotifications.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr'; 2 | 3 | const notificationsCountFetcher = async (): Promise => { 4 | const response = await fetch('/traewelling/notifications/unread'); 5 | 6 | if (!response.ok) { 7 | return 0; 8 | } 9 | 10 | return await response.json(); 11 | }; 12 | 13 | export const useNotificationsCount = () => { 14 | const { data, isLoading } = useSWR( 15 | ['/traewelling/notifications/unread'], 16 | ([_]) => notificationsCountFetcher() 17 | ); 18 | 19 | return { 20 | isLoading, 21 | amount: data ?? 0, 22 | }; 23 | }; 24 | -------------------------------------------------------------------------------- /src/hooks/useOverlayScroll/types.ts: -------------------------------------------------------------------------------- 1 | export type UseOverlayScrollProps = { 2 | disableScroll?: boolean; 3 | }; 4 | -------------------------------------------------------------------------------- /src/hooks/useOverlayScroll/useOverlayScroll.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | import { UseOverlayScrollProps } from './types'; 3 | 4 | export const useOverlayScroll = ({ disableScroll }: UseOverlayScrollProps) => { 5 | const [contentHeight, setContentHeight] = useState(0); 6 | const [viewportHeight, setViewportHeight] = useState(0); 7 | const scrollerRef = useRef(null); 8 | const wrapperRef = useRef(null); 9 | 10 | useEffect(() => { 11 | if (disableScroll) 12 | scrollerRef.current?.scrollTo({ behavior: 'smooth', top: 0 }); 13 | }, [disableScroll]); 14 | 15 | useEffect(() => { 16 | if (!scrollerRef.current || !wrapperRef.current) { 17 | return; 18 | } 19 | 20 | const root = wrapperRef.current; 21 | const viewport = scrollerRef.current; 22 | const content = viewport.children.item(0)!; 23 | 24 | const observer = new ResizeObserver((entries) => { 25 | for (const entry of entries) { 26 | if ( 27 | entry.target.hasAttribute('data-viewport') && 28 | entry.target.clientHeight !== viewportHeight 29 | ) { 30 | setViewportHeight(entry.target.clientHeight); 31 | } else if ( 32 | !entry.target.hasAttribute('data-viewport') && 33 | entry.target.scrollHeight !== contentHeight 34 | ) { 35 | setContentHeight(entry.target.scrollHeight); 36 | } 37 | } 38 | }); 39 | 40 | const handleScroll = () => { 41 | if (viewport.scrollTop <= 0) { 42 | root.style.setProperty('--top-fog-opacity', '0'); 43 | } else if (root.style.getPropertyValue('--top-fog-opacity') !== '1') { 44 | root.style.setProperty('--top-fog-opacity', '1'); 45 | } 46 | 47 | if (viewport.clientHeight + viewport.scrollTop >= viewport.scrollHeight) { 48 | root.style.setProperty('--bottom-fog-opacity', '0'); 49 | } else if (root.style.getPropertyValue('--bottom-fog-opacity') !== '1') { 50 | root.style.setProperty('--bottom-fog-opacity', '1'); 51 | } 52 | }; 53 | 54 | viewport.addEventListener('scroll', handleScroll, { passive: true }); 55 | observer.observe(viewport); 56 | observer.observe(content); 57 | 58 | return () => { 59 | viewport.removeEventListener('scroll', handleScroll); 60 | observer.unobserve(viewport); 61 | observer.unobserve(content); 62 | }; 63 | }, [contentHeight, scrollerRef, viewportHeight, wrapperRef]); 64 | 65 | return { 66 | contentHeight, 67 | scrollerRef, 68 | viewportHeight, 69 | wrapperRef, 70 | }; 71 | }; 72 | -------------------------------------------------------------------------------- /src/hooks/useRecentStations/useRecentStations.ts: -------------------------------------------------------------------------------- 1 | import { AutocompleteResponse } from '@/traewelling-sdk/functions/trains'; 2 | import useSWR from 'swr'; 3 | 4 | const fetcher = async (): Promise => { 5 | const response = await fetch('/traewelling/stations/history'); 6 | 7 | if (!response.ok) { 8 | return []; 9 | } 10 | 11 | return await response.json(); 12 | }; 13 | 14 | export const useRecentStations = () => { 15 | const { data, isLoading } = useSWR( 16 | ['/traewelling/stations/autocomplete'], 17 | ([]) => fetcher() 18 | ); 19 | 20 | return { 21 | isLoading, 22 | recentStations: data, 23 | }; 24 | }; 25 | -------------------------------------------------------------------------------- /src/hooks/useStationSearch/useStationSearch.ts: -------------------------------------------------------------------------------- 1 | import { AutocompleteResponse } from '@/traewelling-sdk/functions/trains'; 2 | import { Station } from '@/traewelling-sdk/types'; 3 | import levenshtein from 'js-levenshtein'; 4 | import useSWR from 'swr'; 5 | 6 | const fetcher = async (query: string): Promise => { 7 | if (!query || query.trim().length < 2) { 8 | return []; 9 | } 10 | 11 | const response = await fetch( 12 | `/traewelling/stations/autocomplete?query=${query}` 13 | ); 14 | 15 | if (!response.ok) { 16 | return []; 17 | } 18 | 19 | return await response.json(); 20 | }; 21 | 22 | const getMatchDetails = ( 23 | { name, rilIdentifier }: Pick, 24 | queryPattern: RegExp 25 | ) => { 26 | const extendedName = `${name}${!rilIdentifier ? '' : ` (${rilIdentifier})`}`; 27 | 28 | const matchedLength = Array.from(extendedName.matchAll(queryPattern)) 29 | .flat() 30 | .reduce((acc, cur) => acc + cur.length, 0); 31 | 32 | return [matchedLength / extendedName.length, extendedName] as const; 33 | }; 34 | 35 | export const useStationSearch = (query: string) => { 36 | const { data, isLoading } = useSWR( 37 | ['/traewelling/stations/autocomplete', query], 38 | ([_, query]) => fetcher(query) 39 | ); 40 | 41 | const escapedQuery = query.replaceAll(/[\|\.\(\)\/\?\*\+\$\^\\]/gi, ''); 42 | const queryPattern = new RegExp( 43 | `(${escapedQuery.trim().split(' ').join('|')})`, 44 | 'gi' 45 | ); 46 | 47 | const stations = data?.sort((a, b) => { 48 | const [valueA, nameA] = getMatchDetails(a, queryPattern); 49 | const [valueB, nameB] = getMatchDetails(b, queryPattern); 50 | 51 | // Always prioritize RIL identifiers 52 | if (a.rilIdentifier === query) { 53 | return -1; 54 | } else if (b.rilIdentifier === query) { 55 | return 1; 56 | } 57 | 58 | // Sort elements by the percentage they match 59 | if (valueA > valueB) { 60 | return -1; 61 | } 62 | 63 | if (valueA < valueB) { 64 | return 1; 65 | } 66 | 67 | if (valueA !== 0 && valueB !== 0) { 68 | return 0; 69 | } 70 | 71 | // Sort elements without any direct matches by levensthein distance 72 | return levenshtein(nameA, query) - levenshtein(nameB, query); 73 | }); 74 | 75 | return { 76 | isLoading, 77 | stations, 78 | }; 79 | }; 80 | -------------------------------------------------------------------------------- /src/hooks/useStatus/useStatus.ts: -------------------------------------------------------------------------------- 1 | import { Status } from '@/traewelling-sdk/types'; 2 | import useSWR from 'swr'; 3 | 4 | const fetcher = async (id: string): Promise => { 5 | if (!id.trim()) { 6 | return null; 7 | } 8 | 9 | const response = await fetch(`/traewelling/statuses/${id}`); 10 | 11 | if (!response.ok) { 12 | return null; 13 | } 14 | 15 | return await response.json(); 16 | }; 17 | 18 | export const useStatus = (id: string) => { 19 | const { data, isLoading } = useSWR(['/traewelling/statuses', id], ([_, id]) => 20 | fetcher(id) 21 | ); 22 | 23 | return { 24 | isLoading, 25 | status: data, 26 | }; 27 | }; 28 | -------------------------------------------------------------------------------- /src/hooks/useStops/useStops.ts: -------------------------------------------------------------------------------- 1 | import { getStopsAfter } from '@/helpers/getStopsAfter'; 2 | import { TripResponse } from '@/traewelling-sdk/functions/trains'; 3 | import useSWR from 'swr'; 4 | 5 | const fetcher = async ( 6 | hafasTripId: string, 7 | lineName: string, 8 | start: string 9 | ): Promise => { 10 | if (!hafasTripId || !lineName || !start.trim()) { 11 | return; 12 | } 13 | 14 | const response = await fetch( 15 | `/traewelling/trips?hafasTripId=${encodeURIComponent( 16 | hafasTripId 17 | )}&lineName=${lineName}&start=${start.replace('/', '%20')}` 18 | ); 19 | 20 | if (!response.ok) { 21 | return; 22 | } 23 | 24 | return await response.json(); 25 | }; 26 | 27 | export const useStops = ( 28 | hafasTripId: string, 29 | lineName: string, 30 | plannedDeparture: string, 31 | start: string 32 | ) => { 33 | const { data, isLoading } = useSWR( 34 | ['/traewelling/trips', hafasTripId, lineName, start], 35 | ([_, hafasTripId, lineName, start]) => fetcher(hafasTripId, lineName, start) 36 | ); 37 | 38 | const stops = data && getStopsAfter(plannedDeparture, start, data.stopovers); 39 | 40 | return { 41 | isLoading, 42 | stops, 43 | }; 44 | }; 45 | -------------------------------------------------------------------------------- /src/hooks/useTrip/useTrip.ts: -------------------------------------------------------------------------------- 1 | import { TripResponse } from '@/traewelling-sdk/functions/trains'; 2 | import useSWR from 'swr'; 3 | 4 | const fetcher = async ( 5 | hafasTripId: string, 6 | lineName: string, 7 | start: string 8 | ): Promise => { 9 | if (!hafasTripId || !lineName || !start.trim()) { 10 | return; 11 | } 12 | 13 | const response = await fetch( 14 | `/traewelling/trips?hafasTripId=${hafasTripId}&lineName=${lineName}&start=${start.replace( 15 | '/', 16 | '%20' 17 | )}` 18 | ); 19 | 20 | if (!response.ok) { 21 | return; 22 | } 23 | 24 | return await response.json(); 25 | }; 26 | 27 | export const useTrip = ( 28 | hafasTripId: string, 29 | lineName: string, 30 | start: string 31 | ) => { 32 | const { data, isLoading } = useSWR( 33 | ['/traewelling/trips', hafasTripId, lineName, start], 34 | ([_, hafasTripId, lineName, start]) => fetcher(hafasTripId, lineName, start) 35 | ); 36 | 37 | return { 38 | isLoading, 39 | trip: data, 40 | }; 41 | }; 42 | -------------------------------------------------------------------------------- /src/hooks/useUmami/types.ts: -------------------------------------------------------------------------------- 1 | export type UmamiTrackEventData = { 2 | type: string; 3 | } & Record; 4 | -------------------------------------------------------------------------------- /src/hooks/useUmami/useUmami.ts: -------------------------------------------------------------------------------- 1 | const useUmami = () => { 2 | return { 3 | trackEvent: (event: string, data: any) => { 4 | try { 5 | window.umami.track(event, data); 6 | } catch (e) { 7 | console.error(e); 8 | } 9 | }, 10 | simpleEvent: (event: string) => { 11 | try { 12 | window.umami.track(event); 13 | } catch (e) { 14 | console.error(e); 15 | } 16 | }, 17 | }; 18 | }; 19 | 20 | export default useUmami; 21 | -------------------------------------------------------------------------------- /src/hooks/useUserStatuses/useUserStatus.ts: -------------------------------------------------------------------------------- 1 | import { Status } from '@/traewelling-sdk/types'; 2 | import useSWR from 'swr'; 3 | 4 | const fetcher = async (username: string): Promise => { 5 | if (!username.trim()) { 6 | return null; 7 | } 8 | 9 | const response = await fetch(`/traewelling/user/${username}/statuses`); 10 | 11 | if (!response.ok) { 12 | return null; 13 | } 14 | 15 | return await response.json(); 16 | }; 17 | 18 | export const useUserStatuses = (username: string) => { 19 | const { data, isLoading } = useSWR( 20 | [`/traewelling/user/${username}/statuses`, username], 21 | ([_, username]) => fetcher(username) 22 | ); 23 | 24 | return { 25 | isLoading, 26 | statuses: data, 27 | }; 28 | }; 29 | -------------------------------------------------------------------------------- /src/overlays/CompleteCheckIn/types.ts: -------------------------------------------------------------------------------- 1 | import { OverlayProps } from '@/components/Overlay/types'; 2 | 3 | export type CompleteCheckInOverlayProps = OverlayProps & { 4 | onComplete: () => Promise | void; 5 | }; 6 | -------------------------------------------------------------------------------- /src/overlays/SelectDestination/SelectDestination.module.scss: -------------------------------------------------------------------------------- 1 | .direction { 2 | font-size: 1.25rem; 3 | font-weight: 600; 4 | } 5 | 6 | .header { 7 | color: var(--contrast); 8 | flex-shrink: 0; 9 | padding-inline: 1rem; 10 | } 11 | 12 | .routeContent { 13 | align-items: flex-start; 14 | display: flex; 15 | gap: 1rem; 16 | padding-bottom: 1rem; 17 | justify-content: space-between; 18 | 19 | &::before { 20 | content: "von"; 21 | font-size: 0.75rem; 22 | font-weight: 500; 23 | opacity: 0.75; 24 | position: absolute; 25 | transform: translateY(-85%) skewX(-6deg); 26 | } 27 | } 28 | 29 | .routeStart { 30 | --line-offset: 0.75rem; 31 | } 32 | 33 | .trip { 34 | align-items: center; 35 | display: flex; 36 | flex-direction: column; 37 | font-weight: 500; 38 | gap: 0.5rem; 39 | margin-bottom: 1.5rem; 40 | text-align: center; 41 | } 42 | -------------------------------------------------------------------------------- /src/overlays/SelectDestination/SelectDestination.overlay.tsx: -------------------------------------------------------------------------------- 1 | import { NewLineIndicator } from '@/components/NewLineIndicator/NewLineIndicator'; 2 | import { Overlay } from '@/components/Overlay/Overlay'; 3 | import Route from '@/components/Route/Route'; 4 | import { StopoverSelector } from '@/components/StopoverSelector/StopoverSelector'; 5 | import ThemeProvider from '@/components/ThemeProvider/ThemeProvider'; 6 | import { Time } from '@/components/Time/Time'; 7 | import { useCheckIn } from '@/hooks/useCheckIn/useCheckIn'; 8 | import { radioCanada } from '@/styles/fonts'; 9 | import { AboardStopover } from '@/types/aboard'; 10 | import { parseSchedule } from '@/utils/parseSchedule'; 11 | import styles from './SelectDestination.module.scss'; 12 | import { SelectDestinationOverlayProps } from './types'; 13 | 14 | export const SelectDestinationOverlay = ({ 15 | onComplete, 16 | ...overlayProps 17 | }: SelectDestinationOverlayProps) => { 18 | const { selectDestination, state } = useCheckIn(); 19 | 20 | const departureStop = state.trip?.stopovers?.find( 21 | (stop) => 22 | stop.station.trwlId === state.origin?.trwlId && 23 | new Date(stop.departure.planned!).toISOString() === 24 | new Date(state.departureTime!).toISOString() 25 | ); 26 | 27 | const departureSchedule = parseSchedule({ 28 | actual: departureStop?.departure.actual, 29 | planned: departureStop?.departure.planned ?? '', 30 | }); 31 | 32 | const handleDestinationSelected = (destination: AboardStopover) => { 33 | selectDestination({ destination }); 34 | onComplete(); 35 | }; 36 | 37 | return ( 38 | 45 | 46 |
    47 | {state.trip && ( 48 |
    49 | 50 | 51 | {state.trip.designation} 52 |
    53 | )} 54 | 55 | 56 | } 59 | stopIndicatorVariant="default" 60 | > 61 |
    62 |
    {state.origin?.name}
    63 | 64 |
    71 |
    72 |
    73 |
    74 | 75 | 76 | 84 | 85 |
    86 |
    87 | ); 88 | }; 89 | -------------------------------------------------------------------------------- /src/overlays/SelectDestination/types.ts: -------------------------------------------------------------------------------- 1 | import { OverlayProps } from '@/components/Overlay/types'; 2 | 3 | export type SelectDestinationOverlayProps = OverlayProps & { 4 | onComplete: () => void; 5 | }; 6 | -------------------------------------------------------------------------------- /src/page-templates/dashboard.module.scss: -------------------------------------------------------------------------------- 1 | .legal { 2 | display: flex; 3 | gap: 0.5rem; 4 | justify-content: center; 5 | margin: 2rem 0 5rem; 6 | padding: 0 0 5rem 0; 7 | 8 | a { 9 | color: var(--slate-7); 10 | text-decoration: none; 11 | font-weight: 400; 12 | font-size: 0.9rem; 13 | line-height: 1.5rem; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/page-templates/dashboard.tsx: -------------------------------------------------------------------------------- 1 | import CheckIn from '@/components/CheckIn/CheckIn'; 2 | import Navbar from '@/components/Navbar/Navbar'; 3 | import Statuses from '@/components/Statuses/Statuses'; 4 | import Link from 'next/link'; 5 | import styles from './dashboard.module.scss'; 6 | 7 | const DashboardHome = () => { 8 | return ( 9 |
    10 | 11 | 12 | 13 | 14 | 15 | 16 |
    17 | Impressum 18 | Datenschutz 19 |
    20 |
    21 | ); 22 | }; 23 | 24 | export default DashboardHome; 25 | -------------------------------------------------------------------------------- /src/pages/_error.jsx: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/nextjs'; 2 | import Error from 'next/error'; 3 | 4 | const CustomErrorComponent = (props) => { 5 | return ; 6 | }; 7 | 8 | CustomErrorComponent.getInitialProps = async (contextData) => { 9 | // In case this is running in a serverless function, await this in order to give Sentry 10 | // time to send the error before the lambda exits 11 | await Sentry.captureUnderscoreErrorException(contextData); 12 | 13 | // This will contain the status code of the response 14 | return Error.getInitialProps(contextData); 15 | }; 16 | 17 | export default CustomErrorComponent; 18 | -------------------------------------------------------------------------------- /src/pages/api/auth/[...nextauth].ts: -------------------------------------------------------------------------------- 1 | import NextAuth, { AuthOptions } from 'next-auth'; 2 | 3 | export const authOptions: AuthOptions = { 4 | callbacks: { 5 | jwt: async ({ token, account, user }) => { 6 | if (user) { 7 | token.username = user.username; 8 | token.displayName = user.name; 9 | token.id = user.id as number; 10 | token.picture = user.image; 11 | } 12 | 13 | token.accessToken = account?.access_token || token.accessToken; 14 | token.refreshToken = account?.refresh_token || token.refreshToken; 15 | 16 | return token; 17 | }, 18 | session: async ({ session, token, user }) => { 19 | session.user.accessToken = token.accessToken; 20 | session.user.refreshToken = token.refreshToken; 21 | session.user.username = token.username; 22 | session.user.name = token.displayName; 23 | session.user.id = token.id; 24 | session.user.image = token.picture; 25 | 26 | return session; 27 | }, 28 | }, 29 | providers: [ 30 | { 31 | id: 'traewelling', 32 | name: 'Träwelling', 33 | version: '2.0', 34 | type: 'oauth', 35 | authorization: { 36 | url: 'https://traewelling.de/oauth/authorize', 37 | params: { 38 | scope: [ 39 | 'read-statuses', 40 | 'read-notifications', 41 | 'write-statuses', 42 | 'write-likes', 43 | 'write-notifications', 44 | 'write-follows', 45 | 'write-blocks', 46 | 'read-settings', 47 | 'read-settings-profile', 48 | 'read-settings-followers', 49 | 'write-followers', 50 | ].join(' '), 51 | }, 52 | }, 53 | userinfo: 'https://traewelling.de/api/v1/auth/user', 54 | profileUrl: 'https://traewelling.de/api/v1/auth/user', 55 | token: 'https://traewelling.de/oauth/token', 56 | clientId: process.env.TRAEWELLING_CLIENT_ID, 57 | clientSecret: process.env.TRAEWELLING_CLIENT_SECRET, 58 | profile({ data: profile }) { 59 | return { 60 | id: profile.id, 61 | name: profile.displayName, 62 | username: profile.username, 63 | email: profile.email, 64 | image: profile.profilePicture, 65 | mastodonUrl: profile.mastodonUrl, 66 | privateProfile: profile.privateProfile, 67 | preventIndex: profile.preventIndex, 68 | language: profile.language, 69 | defaultStatusVisibility: profile.defaultStatusVisibility, 70 | }; 71 | }, 72 | }, 73 | ], 74 | session: { 75 | maxAge: 365 * 24 * 60 * 60, 76 | }, 77 | pages: { 78 | signIn: '/login', 79 | }, 80 | }; 81 | 82 | export default NextAuth(authOptions); 83 | -------------------------------------------------------------------------------- /src/scripts/UmamiScript/UmamiScript.tsx: -------------------------------------------------------------------------------- 1 | import Script from 'next/script'; 2 | 3 | const WEBSITE_IDS = { 4 | production: '13b8972f-5450-4cd9-a024-f81b0def6407', 5 | development: '', 6 | test: '', 7 | }; 8 | 9 | const UmamiScript = () => { 10 | const websiteId = WEBSITE_IDS[process.env.NODE_ENV]; 11 | 12 | if (!websiteId) { 13 | return null; 14 | } 15 | 16 | return ( 17 |