9 |
10 |
11 | );
12 | }
--------------------------------------------------------------------------------
/load-context.ts:
--------------------------------------------------------------------------------
1 |
2 | import { type PlatformProxy } from "wrangler";
3 | // When using `wrangler.toml` to configure bindings,
4 | // `wrangler types` will generate types for those bindings
5 | // into the global `Env` interface.
6 | // Need this empty interface so that typechecking passes
7 | // even if no `wrangler.toml` exists.
8 | // eslint-disable-next-line @typescript-eslint/no-empty-interface
9 | interface Env {}
10 | type Cloudflare = Omit, "dispose">;
11 | declare module "@react-router/cloudflare" {
12 | interface AppLoadContext {
13 | cloudflare: Cloudflare;
14 | }
15 | }
--------------------------------------------------------------------------------
/app/components/TransitionWrapper.tsx:
--------------------------------------------------------------------------------
1 | import { motion } from "motion/react";
2 |
3 | export function TransitionWrapper({
4 | children,
5 | className,
6 | key,
7 | }: {
8 | children: React.ReactNode;
9 | className?: string;
10 | key?: string;
11 | }) {
12 | return (
13 |
20 | {children}
21 |
22 | );
23 | }
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:20-alpine AS development-dependencies-env
2 | COPY . /app
3 | WORKDIR /app
4 | RUN npm ci
5 |
6 | FROM node:20-alpine AS production-dependencies-env
7 | COPY ./package.json package-lock.json /app/
8 | WORKDIR /app
9 | RUN npm ci --omit=dev
10 |
11 | FROM node:20-alpine AS build-env
12 | COPY . /app/
13 | COPY --from=development-dependencies-env /app/node_modules /app/node_modules
14 | WORKDIR /app
15 | RUN npm run build
16 |
17 | FROM node:20-alpine
18 | COPY ./package.json package-lock.json /app/
19 | COPY --from=production-dependencies-env /app/node_modules /app/node_modules
20 | COPY --from=build-env /app/build /app/build
21 | WORKDIR /app
22 | CMD ["npm", "run", "start"]
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": [
3 | "**/*",
4 | "**/.server/**/*",
5 | "**/.client/**/*",
6 | ".react-router/types/**/*"
7 | ],
8 | "compilerOptions": {
9 | "lib": ["DOM", "DOM.Iterable", "ES2022"],
10 | "types": ["@react-router/cloudflare", "vite/client"],
11 | "target": "ES2022",
12 | "module": "ES2022",
13 | "moduleResolution": "bundler",
14 | "jsx": "react-jsx",
15 | "rootDirs": [".", "./.react-router/types"],
16 | "baseUrl": ".",
17 | "paths": {
18 | "~/*": ["./app/*"]
19 | },
20 | "esModuleInterop": true,
21 | "verbatimModuleSyntax": true,
22 | "noEmit": true,
23 | "resolveJsonModule": true,
24 | "skipLibCheck": true,
25 | "strict": true
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: [zackptr] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
4 | patreon: # Replace with a single Patreon username
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: zackptr # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
12 | polar: # Replace with a single Polar username
13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
14 | thanks_dev: # Replace with a single thanks.dev username
15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
16 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Zackry Rosli
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "commute-my",
3 | "private": true,
4 | "type": "module",
5 | "scripts": {
6 | "build": "react-router build",
7 | "dev": "react-router dev",
8 | "start": "react-router-serve ./build/server/index.js",
9 | "typecheck": "react-router typegen && tsc",
10 | "generate-pwa-assets": "pwa-assets-generator --preset minimal public/logo.svg"
11 | },
12 | "dependencies": {
13 | "@motis-project/motis-client": "^2.7.6",
14 | "@react-router/cloudflare": "^7.1.1",
15 | "@react-router/serve": "^7.1.1",
16 | "@tanstack/react-query": "^5.90.12",
17 | "class-variance-authority": "^0.7.1",
18 | "isbot": "^5.1.17",
19 | "lucide-react": "^0.473.0",
20 | "motion": "^11.18.1",
21 | "react": "^19.0.0",
22 | "react-dom": "^19.0.0",
23 | "react-router": "^7.1.1",
24 | "tailwind-merge": "^3.0.1"
25 | },
26 | "devDependencies": {
27 | "@react-router/dev": "^7.1.1",
28 | "@types/node": "^20",
29 | "@types/react": "^19.0.1",
30 | "@types/react-dom": "^19.0.1",
31 | "@vite-pwa/assets-generator": "^0.2.6",
32 | "autoprefixer": "^10.4.20",
33 | "postcss": "^8.4.49",
34 | "tailwindcss": "^4.0.8",
35 | "typescript": "^5.7.2",
36 | "vite": "^5.4.11",
37 | "vite-plugin-pwa": "^0.21.1",
38 | "vite-tsconfig-paths": "^5.1.4",
39 | "wrangler": "^3.102.0",
40 | "@tailwindcss/postcss": "^4.0.8",
41 | "@tailwindcss/vite": "^4.0.8"
42 | }
43 | }
--------------------------------------------------------------------------------
/app/lib/rapidklIcons.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * RapidKL Line Icon Mapping
3 | * Icons from https://myrapid.com.my
4 | */
5 |
6 | export const RAPIDKL_LINE_ICONS: Record = {
7 | "AG": "/icons/rapidkl/icon_line_ampang.png",
8 | "SP": "/icons/rapidkl/icon_line_sri-petaling.png",
9 | "KJ": "/icons/rapidkl/icon_line_kelana-jaya.png",
10 | "MR": "/icons/rapidkl/icon_line_kl-monorail.png",
11 | "KG": "/icons/rapidkl/icon_line_kajang-01.png",
12 | "PY": "/icons/rapidkl/icon_line_putrajaya-01.png",
13 | };
14 |
15 | export const RAPIDKL_STATION_ICONS = {
16 | interchange: "/icons/rapidkl/icon_interchange-station.png",
17 | connecting: "/icons/rapidkl/icon_connecting-station.png",
18 | };
19 |
20 | // RapidKL official line colors (extracted from their branding)
21 | export const RAPIDKL_LINE_COLORS: Record = {
22 | "AG": "#FF8E10", // Ampang Line - Orange
23 | "SP": "#8D0C06", // Sri Petaling Line - Dark Red
24 | "KJ": "#ED0F4C", // Kelana Jaya Line - Magenta/Red
25 | "MR": "#81BC00", // KL Monorail - Green
26 | "KG": "#008640", // Kajang Line - Dark Green
27 | "PY": "#FBCD20", // Putrajaya Line - Yellow
28 | };
29 |
30 | export function getLineIconUrl(lineId: string): string {
31 | return RAPIDKL_LINE_ICONS[lineId] || RAPIDKL_LINE_ICONS["AG"];
32 | }
33 |
34 | export function getLineColor(lineId: string): string {
35 | return RAPIDKL_LINE_COLORS[lineId] || RAPIDKL_LINE_COLORS["AG"];
36 | }
37 |
38 |
--------------------------------------------------------------------------------
/app/entry.server.tsx:
--------------------------------------------------------------------------------
1 | import type { AppLoadContext, EntryContext } from "react-router";
2 | import { ServerRouter } from "react-router";
3 | import { isbot } from "isbot";
4 | import { renderToReadableStream } from "react-dom/server";
5 |
6 | export default async function handleRequest(
7 | request: Request,
8 | responseStatusCode: number,
9 | responseHeaders: Headers,
10 | routerContext: EntryContext,
11 | _loadContext: AppLoadContext
12 | ) {
13 | let shellRendered = false;
14 | const userAgent = request.headers.get("user-agent");
15 |
16 | const body = await renderToReadableStream(
17 | ,
18 | {
19 | onError(error: unknown) {
20 | responseStatusCode = 500;
21 | // Log streaming rendering errors from inside the shell. Don't log
22 | // errors encountered during initial shell rendering since they'll
23 | // reject and get logged in handleDocumentRequest.
24 | if (shellRendered) {
25 | console.error(error);
26 | }
27 | },
28 | }
29 | );
30 | shellRendered = true;
31 |
32 | // Ensure requests from bots and SPA Mode renders wait for all content to load before responding
33 | // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
34 | if ((userAgent && isbot(userAgent)) || routerContext.isSpaMode) {
35 | await body.allReady;
36 | }
37 |
38 | responseHeaders.set("Content-Type", "text/html");
39 | return new Response(body, {
40 | headers: responseHeaders,
41 | status: responseStatusCode,
42 | });
43 | }
--------------------------------------------------------------------------------
/app/app.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 |
3 | html,
4 | body {
5 | @apply font-sans bg-white text-black dark:bg-dark-950 dark:text-white appearance-none;
6 |
7 | @media (prefers-color-scheme: dark) {
8 | color-scheme: dark;
9 | }
10 | }
11 |
12 | html {
13 | scroll-behavior: smooth;
14 | }
15 |
16 | @theme {
17 | --font-sans: "Open Sans", sans-serif;
18 |
19 | --color-steel-blue-50: #f2f7fc;
20 | --color-steel-blue-100: #e1edf8;
21 | --color-steel-blue-200: #c9e0f4;
22 | --color-steel-blue-300: #a4cdec;
23 | --color-steel-blue-400: #79b2e1;
24 | --color-steel-blue-500: #5995d8;
25 | --color-steel-blue-600: #457ccb;
26 | --color-steel-blue-700: #3b68ba;
27 | --color-steel-blue-800: #355698;
28 | --color-steel-blue-900: #2f4979;
29 | --color-steel-blue-950: #212e4a;
30 |
31 | --color-dark-950: #0E0E11;
32 | --color-dark-900: #18181B;
33 | --color-dark-800: #27272A;
34 |
35 | /* LRT Ampang */
36 | --color-tangerine-500: #F5911F;
37 | --color-tangerine-900: #835018;
38 |
39 | /* LRT Sri Petaling */
40 | --color-crimson-500: #8D0C06;
41 | --color-crimson-900: #510E0D;
42 |
43 | /* LRT Kelana Jaya */
44 | --color-magenta-500: #ED0F4C;
45 | --color-magenta-900: #74102C;
46 |
47 | /* Monorail KL */
48 | --color-chartreuse-500: #81BC00;
49 | --color-chartreuse-900: #517309;
50 |
51 | /* MRT Kajang */
52 | --color-jade-500: #008640;
53 | --color-jade-900: #08532D;
54 |
55 | /* MRT Putrajaya */
56 | --color-saffron-500: #FBCD20;
57 | --color-saffron-900: #746118;
58 | }
59 |
60 | @utility neomorphism-shadow-* {
61 | box-shadow: inset 0px 1px 0px 0px rgba(255, 255, 255, 0.35), 0px 3px 2px 0px --value(--color-*);
62 | }
--------------------------------------------------------------------------------
/app/routes/donate.tsx:
--------------------------------------------------------------------------------
1 | import { LucideCoffee, LucideGithub, LucideWallet } from "lucide-react";
2 | import { Link } from "react-router";
3 | import { TransitionWrapper } from "~/components/TransitionWrapper";
4 |
5 | export default function Donate() {
6 | return (
7 |
8 |
9 |
Donate
10 |
11 | Love using this public transport journey planner? Support our work to keep it running smoothly and growing! Your contributions directly fund maintenance, new features, and up-to-date transit information. Every donation, regardless of size, helps sustain this open-source project and improves public transportation accessibility for all users. Thanks for considering a contribution!
12 |
Making Klang Valley public transport easier for everyone – locals & tourists alike.
20 |
21 | The Best Way to Plan Your Trip
22 |
23 |
24 | The project aims to make public transportation in the Klang Valley more accessible to everyone, including tourists. No ads, no trackers, no paywalls — just reliable and seamless functionality wherever and whenever you need it.
25 |
26 |
27 | Motivation
28 |
29 |
30 | Commute was developed for the public good, prioritising user protection from ads and malware found in alternative solutions. We believe software should be open, accessible, and secure.
31 |
32 |
33 | Open
34 |
35 |
36 | We stay closely connected with our community, collaborating to make Commute even more valuable. Explore our source code and contribute on GitHub — we greatly appreciate your feedback and support!
37 |
38 |
39 |
40 | );
41 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Welcome to React Router!
2 |
3 | A modern, production-ready template for building full-stack React applications using React Router.
4 |
5 | [](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default)
6 |
7 | ## Features
8 |
9 | - 🚀 Server-side rendering
10 | - ⚡️ Hot Module Replacement (HMR)
11 | - 📦 Asset bundling and optimization
12 | - 🔄 Data loading and mutations
13 | - 🔒 TypeScript by default
14 | - 🎉 TailwindCSS for styling
15 | - 📖 [React Router docs](https://reactrouter.com/)
16 |
17 | ## Getting Started
18 |
19 | ### Installation
20 |
21 | Install the dependencies:
22 |
23 | ```bash
24 | npm install
25 | ```
26 |
27 | ### Development
28 |
29 | Start the development server with HMR:
30 |
31 | ```bash
32 | npm run dev
33 | ```
34 |
35 | Your application will be available at `http://localhost:5173`.
36 |
37 | ## Building for Production
38 |
39 | Create a production build:
40 |
41 | ```bash
42 | npm run build
43 | ```
44 |
45 | ## Deployment
46 |
47 | ### Docker Deployment
48 |
49 | This template includes three Dockerfiles optimized for different package managers:
50 |
51 | - `Dockerfile` - for npm
52 | - `Dockerfile.pnpm` - for pnpm
53 | - `Dockerfile.bun` - for bun
54 |
55 | To build and run using Docker:
56 |
57 | ```bash
58 | # For npm
59 | docker build -t my-app .
60 |
61 | # For pnpm
62 | docker build -f Dockerfile.pnpm -t my-app .
63 |
64 | # For bun
65 | docker build -f Dockerfile.bun -t my-app .
66 |
67 | # Run the container
68 | docker run -p 3000:3000 my-app
69 | ```
70 |
71 | The containerized application can be deployed to any platform that supports Docker, including:
72 |
73 | - AWS ECS
74 | - Google Cloud Run
75 | - Azure Container Apps
76 | - Digital Ocean App Platform
77 | - Fly.io
78 | - Railway
79 |
80 | ### DIY Deployment
81 |
82 | If you're familiar with deploying Node applications, the built-in app server is production-ready.
83 |
84 | Make sure to deploy the output of `npm run build`
85 |
86 | ```
87 | ├── package.json
88 | ├── package-lock.json (or pnpm-lock.yaml, or bun.lockb)
89 | ├── build/
90 | │ ├── client/ # Static assets
91 | │ └── server/ # Server-side code
92 | ```
93 |
94 | ## Styling
95 |
96 | This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer.
97 |
98 | ---
99 |
100 | Built with ❤️ using React Router.
101 |
--------------------------------------------------------------------------------
/app/lib/motis.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * MOTIS API Client using @motis-project/motis-client
3 | * Documentation: https://transitous.org/api/
4 | */
5 |
6 | import { geocode, plan, type Match, type Place, type PlanResponse, type GeocodeResponse } from '@motis-project/motis-client';
7 |
8 | const MOTIS_API_BASE = 'https://api.transitous.org';
9 |
10 | export interface Location {
11 | lat: number;
12 | lng: number;
13 | name?: string;
14 | id?: string; // Station ID (e.g., "PY05") - will be prefixed with "my-rail-kl_" when used in API
15 | }
16 |
17 | /**
18 | * Search for routes between two locations using MOTIS plan API
19 | * Uses Place format: "lat,lng" or stop ID
20 | */
21 | export async function searchRoutes(
22 | from: Location,
23 | to: Location,
24 | startTime?: Date
25 | ): Promise {
26 | const fromPlace = from.id ? `my-rail-kl_${from.id}` : `${from.lat},${from.lng}`;
27 | const toPlace = to.id ? `my-rail-kl_${to.id}` : `${to.lat},${to.lng}`;
28 |
29 | const query = {
30 | fromPlace,
31 | toPlace,
32 | arriveBy: false,
33 | detailedTransfers: false,
34 | transitModes: "WALK,BUS,RAIL",
35 | fastestDirectFactor: 1.5,
36 | joinInterlinedLegs:false,
37 | maxMatchingDistance:250,
38 | ...(startTime && { time: startTime.toISOString() }),
39 | };
40 |
41 | try {
42 | const response = await plan({
43 | baseUrl: MOTIS_API_BASE,
44 | query: query as unknown as Parameters[0]['query'],
45 | });
46 |
47 | if (response.error) {
48 | throw new Error(`MOTIS API error: ${JSON.stringify(response.error)}`);
49 | }
50 |
51 | return response.data as PlanResponse;
52 | } catch (error) {
53 | if (error instanceof Error) {
54 | throw error;
55 | }
56 | throw new Error('Failed to fetch routes from MOTIS API');
57 | }
58 | }
59 |
60 | /**
61 | * Geocode a location name to coordinates using MOTIS geocode API
62 | */
63 | export async function geocodeLocation(query: string): Promise {
64 | if (!query || query.trim().length < 2) {
65 | return [];
66 | }
67 |
68 | try {
69 | const response = await geocode({
70 | baseUrl: MOTIS_API_BASE,
71 | query: {
72 | text: query.trim(),
73 | },
74 | });
75 |
76 | if (response.error) {
77 | console.error('Geocoding error:', response.error);
78 | return [];
79 | }
80 |
81 | return (response.data || []) as Match[];
82 | } catch (error) {
83 | console.error('Geocoding error:', error);
84 | return [];
85 | }
86 | }
87 |
88 | export type { Match, Place, PlanResponse };
89 |
90 |
--------------------------------------------------------------------------------
/app/components/Button.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, useContext, type ButtonHTMLAttributes, type PropsWithChildren } from "react";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | // TODO: Make this more flexible.
5 | type ButtonVariant = "primary" | "LRT_AG" | "LRT_SP" | "MR_MR" | "LRT_KJ" | "MRT_KG" | "MRT_PY" | string;
6 |
7 | interface ButtonContextValue {
8 | variant: ButtonVariant;
9 | }
10 |
11 | const ButtonContext = createContext(null);
12 |
13 | interface ButtonRootProps extends ButtonHTMLAttributes {
14 | variant?: ButtonVariant;
15 | }
16 |
17 | const ButtonRoot: React.FC = ({ children, variant = "primary", className, ...props }) => {
18 | return (
19 |
20 |
36 |
37 | );
38 | };
39 |
40 | interface ButtonIconProps {
41 | icon: React.ReactElement;
42 | className?: string;
43 | }
44 |
45 | const ButtonIcon: React.FC = ({ icon, className }) => {
46 | const context = useContext(ButtonContext);
47 | if (!context) {
48 | throw new Error("Button.Icon must be used within Button.Root");
49 | }
50 |
51 | return (
52 |
53 | {icon}
54 |
55 | );
56 | };
57 |
58 | type ButtonTextProps = PropsWithChildren<{
59 | className?: string;
60 | }>
61 |
62 | const ButtonText: React.FC = ({ children, className = '' }) => {
63 | const context = useContext(ButtonContext);
64 | if (!context) throw new Error('Button.Text must be used within Button.Root');
65 |
66 | return {children};
67 | };
68 |
69 | export const Button = {
70 | Root: ButtonRoot,
71 | Icon: ButtonIcon,
72 | Text: ButtonText,
73 | };
--------------------------------------------------------------------------------
/app/root.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | isRouteErrorResponse,
3 | Links,
4 | Meta,
5 | Outlet,
6 | Scripts,
7 | ScrollRestoration,
8 | } from "react-router";
9 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
10 |
11 | import type { Route } from "./+types/root";
12 | import stylesheet from "./app.css?url";
13 | import { Navigation } from "~/components/Navigation";
14 |
15 | const queryClient = new QueryClient({
16 | defaultOptions: {
17 | queries: {
18 | staleTime: 60 * 1000,
19 | refetchOnWindowFocus: false,
20 | },
21 | },
22 | });
23 |
24 | export const links: Route.LinksFunction = () => [
25 | { rel: "preconnect", href: "https://fonts.googleapis.com" },
26 | {
27 | rel: "preconnect",
28 | href: "https://fonts.gstatic.com",
29 | crossOrigin: "anonymous",
30 | },
31 | {
32 | rel: "stylesheet",
33 | href: "https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300..800;1,300..800&display=swap",
34 | },
35 | { rel: "stylesheet", href: stylesheet },
36 | { rel: "icon", href: "/favicon.ico" },
37 | { rel: "apple-touch-icon", href: "/apple-touch-icon-180x180.png", sizes: "180x180" },
38 | { rel: "mask-icon", href: "/mask-icon.svg", sizes: "#000000" },
39 | { rel: "manifest", href: "/manifest.webmanifest" },
40 | ];
41 |
42 | export function Layout({ children }: { children: React.ReactNode }) {
43 | return (
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 | {children}
58 |
59 |
60 |
61 |
62 | );
63 | }
64 |
65 | export default function App() {
66 | return (
67 |
68 |
69 |
70 | );
71 | }
72 |
73 | export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
74 | let message = "Oops!";
75 | let details = "An unexpected error occurred.";
76 | let stack: string | undefined;
77 |
78 | if (isRouteErrorResponse(error)) {
79 | message = error.status === 404 ? "404" : "Error";
80 | details =
81 | error.status === 404
82 | ? "The requested page could not be found."
83 | : error.statusText || details;
84 | } else if (import.meta.env.DEV && error && error instanceof Error) {
85 | details = error.message;
86 | stack = error.stack;
87 | }
88 |
89 | return (
90 |
91 |
{message}
92 |
{details}
93 | {stack && (
94 |
95 | {stack}
96 |
97 | )}
98 |
99 | );
100 | }
101 |
--------------------------------------------------------------------------------
/app/routes/home.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { TransitionWrapper } from "~/components/TransitionWrapper";
3 | import type { Route } from "./+types/home";
4 | import { lines } from "~/lib/line";
5 | import { Link, useNavigate } from "react-router";
6 | import { LucideArrowUpDown, LucideCircleDot, LucideMapPin } from "lucide-react";
7 | import { Button } from "~/components/Button";
8 | import { LocationAutocomplete } from "~/components/LocationAutocomplete";
9 | import type { Location } from "~/lib/motis";
10 | import { getLineIconUrl } from "~/lib/rapidklIcons";
11 |
12 | export function meta({}: Route.MetaArgs) {
13 | return [
14 | { title: "Commute" },
15 | { description: "A project aims to make public transportation in the Klang Valley more accessible to everyone, including tourists." },
16 | { property: "og:title", content: "Commute" },
17 | { property: "og:description", content: "A project aims to make public transportation in the Klang Valley more accessible to everyone, including tourists." },
18 | ];
19 | }
20 |
21 | export default function Home() {
22 | const navigate = useNavigate();
23 | const [origin, setOrigin] = useState("");
24 | const [destination, setDestination] = useState("");
25 | const [originCoords, setOriginCoords] = useState(null);
26 | const [destinationCoords, setDestinationCoords] = useState(null);
27 |
28 | const handleSwap = () => {
29 | const temp = origin;
30 | setOrigin(destination);
31 | setDestination(temp);
32 | const tempCoords = originCoords;
33 | setOriginCoords(destinationCoords);
34 | setDestinationCoords(tempCoords);
35 | };
36 |
37 | const handleSubmit = (e: React.FormEvent) => {
38 | e.preventDefault();
39 |
40 | if (!originCoords) {
41 | alert('Please select an origin location');
42 | return;
43 | }
44 |
45 | if (!destinationCoords) {
46 | alert('Please select a destination location');
47 | return;
48 | }
49 |
50 | const params = new URLSearchParams({
51 | fromLat: originCoords.lat.toString(),
52 | fromLng: originCoords.lng.toString(),
53 | fromName: originCoords.name || '',
54 | toLat: destinationCoords.lat.toString(),
55 | toLng: destinationCoords.lng.toString(),
56 | toName: destinationCoords.name || '',
57 | });
58 |
59 | if (originCoords.id) {
60 | params.set('fromId', originCoords.id);
61 | }
62 | if (destinationCoords.id) {
63 | params.set('toId', destinationCoords.id);
64 | }
65 |
66 | navigate(`/search?${params.toString()}`);
67 | };
68 |
69 | return (
70 |
71 |
72 |
73 |
74 | Commute
75 |
76 |
77 | Making Klang Valley public transport easier for everyone – locals & tourists alike.
78 |