diff --git a/.gitea/workflows/test.yaml b/.gitea/workflows/test.yaml
new file mode 100644
index 0000000..8e833fe
--- /dev/null
+++ b/.gitea/workflows/test.yaml
@@ -0,0 +1,10 @@
+name: Test
+run-name: Test?
+on: [push]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - run: npm install && npm run test
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index fdd07b4..647c8f1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,9 @@
.env
data/
node_modules/
-public/
-build/
\ No newline at end of file
+build/
+/public/entry.worker.js
+/test-results/
+/playwright-report/
+/blob-report/
+/playwright/.cache/
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..f010f27
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,20 @@
+{
+ "trailingComma": "es5",
+ "tabWidth": 2,
+ "semi": false,
+ "singleQuote": false,
+ "maxLineLength": 120,
+ "overrides": [
+ {
+ "files": [
+ "*.test.ts",
+ "*.test.js",
+ "*.spec.ts",
+ "*.spec.js"
+ ],
+ "options": {
+ "maxLineLength": 9999999
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/.swcrc b/.swcrc
new file mode 100644
index 0000000..5b29ac2
--- /dev/null
+++ b/.swcrc
@@ -0,0 +1,31 @@
+{
+ "jsc": {
+ "target": "es2022",
+ "parser": {
+ "syntax": "typescript",
+ "tsx": true,
+ "decorators": false,
+ "dynamicImport": false
+ },
+ "transform": {
+ "react": {
+ "pragma": "React.createElement",
+ "pragmaFrag": "React.Fragment",
+ "throwIfNamespace": true,
+ "development": false,
+ "useBuiltins": false,
+ "runtime": "automatic"
+ },
+ "hidden": {
+ "jest": true
+ }
+ }
+ },
+ "module": {
+ "type": "commonjs",
+ "strict": false,
+ "strictMode": true,
+ "lazy": false,
+ "noInterop": false
+ }
+}
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index 070827a..4e3eb0f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM node
+FROM node:22-alpine
WORKDIR /tack-up-now
COPY . .
RUN npm install
diff --git a/api/subscribe.ts b/api/subscribe.ts
new file mode 100644
index 0000000..1b4a57e
--- /dev/null
+++ b/api/subscribe.ts
@@ -0,0 +1,5 @@
+import type { Response, Request } from "express"
+
+export function subscribe(req: Request, res: Response) {
+ res.status(200).send()
+}
diff --git a/app/ClientOnly.tsx b/app/ClientOnly.tsx
new file mode 100644
index 0000000..b436933
--- /dev/null
+++ b/app/ClientOnly.tsx
@@ -0,0 +1,19 @@
+import { ReactNode, useEffect, useState } from "react"
+
+export default function ClientOnly({
+ fallback,
+ children,
+}: {
+ fallback: ReactNode
+ children(): ReactNode
+}) {
+ const isClient = typeof window !== "undefined"
+
+ const [rendered, setRendered] = useState(false)
+
+ useEffect(() => {
+ setRendered(true)
+ }, [])
+
+ return isClient && rendered ? children() : fallback
+}
diff --git a/app/entry.client.tsx b/app/entry.client.tsx
index 94d5dc0..8f13e78 100644
--- a/app/entry.client.tsx
+++ b/app/entry.client.tsx
@@ -4,9 +4,10 @@
* For more information, see https://remix.run/file-conventions/entry.client
*/
-import { RemixBrowser } from "@remix-run/react";
-import { startTransition, StrictMode } from "react";
-import { hydrateRoot } from "react-dom/client";
+import { RemixBrowser } from "@remix-run/react"
+import React from "react"
+import { startTransition, StrictMode } from "react"
+import { hydrateRoot } from "react-dom/client"
startTransition(() => {
hydrateRoot(
@@ -14,5 +15,5 @@ startTransition(() => {
- );
-});
+ )
+})
diff --git a/app/entry.server.tsx b/app/entry.server.tsx
index 45db322..a119799 100644
--- a/app/entry.server.tsx
+++ b/app/entry.server.tsx
@@ -4,15 +4,16 @@
* For more information, see https://remix.run/file-conventions/entry.server
*/
-import { PassThrough } from "node:stream";
+import { PassThrough } from "node:stream"
-import type { AppLoadContext, EntryContext } from "@remix-run/node";
-import { createReadableStreamFromReadable } from "@remix-run/node";
-import { RemixServer } from "@remix-run/react";
-import { isbot } from "isbot";
-import { renderToPipeableStream } from "react-dom/server";
+import type { AppLoadContext, EntryContext } from "@remix-run/node"
+import { createReadableStreamFromReadable } from "@remix-run/node"
+import { RemixServer } from "@remix-run/react"
+import { isbot } from "isbot"
+import { renderToPipeableStream } from "react-dom/server"
+import React from "react"
-const ABORT_DELAY = 5_000;
+const ABORT_DELAY = 5_000
export default function handleRequest(
request: Request,
@@ -36,7 +37,7 @@ export default function handleRequest(
responseStatusCode,
responseHeaders,
remixContext
- );
+ )
}
function handleBotRequest(
@@ -46,7 +47,7 @@ function handleBotRequest(
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
- let shellRendered = false;
+ let shellRendered = false
const { pipe, abort } = renderToPipeableStream(
,
{
onAllReady() {
- shellRendered = true;
- const body = new PassThrough();
- const stream = createReadableStreamFromReadable(body);
+ shellRendered = true
+ const body = new PassThrough()
+ const stream = createReadableStreamFromReadable(body)
- responseHeaders.set("Content-Type", "text/html");
+ responseHeaders.set("Content-Type", "text/html")
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
- );
+ )
- pipe(body);
+ pipe(body)
},
onShellError(error: unknown) {
- reject(error);
+ reject(error)
},
onError(error: unknown) {
- responseStatusCode = 500;
+ responseStatusCode = 500
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
- console.error(error);
+ console.error(error)
}
},
}
- );
+ )
- setTimeout(abort, ABORT_DELAY);
- });
+ setTimeout(abort, ABORT_DELAY)
+ })
}
function handleBrowserRequest(
@@ -96,7 +97,7 @@ function handleBrowserRequest(
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
- let shellRendered = false;
+ let shellRendered = false
const { pipe, abort } = renderToPipeableStream(
,
{
onShellReady() {
- shellRendered = true;
- const body = new PassThrough();
- const stream = createReadableStreamFromReadable(body);
+ shellRendered = true
+ const body = new PassThrough()
+ const stream = createReadableStreamFromReadable(body)
- responseHeaders.set("Content-Type", "text/html");
+ responseHeaders.set("Content-Type", "text/html")
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
- );
+ )
- pipe(body);
+ pipe(body)
},
onShellError(error: unknown) {
- reject(error);
+ reject(error)
},
onError(error: unknown) {
- responseStatusCode = 500;
+ responseStatusCode = 500
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
- console.error(error);
+ console.error(error)
}
},
}
- );
+ )
- setTimeout(abort, ABORT_DELAY);
- });
+ setTimeout(abort, ABORT_DELAY)
+ })
}
diff --git a/app/entry.worker.ts b/app/entry.worker.ts
new file mode 100644
index 0000000..10fa180
--- /dev/null
+++ b/app/entry.worker.ts
@@ -0,0 +1,17 @@
+///
+
+export {}
+
+declare let self: ServiceWorkerGlobalScope
+
+self.addEventListener("install", (event) => {
+ console.log("Service worker installed")
+
+ event.waitUntil(self.skipWaiting())
+})
+
+self.addEventListener("activate", (event) => {
+ console.log("Service worker activated")
+
+ event.waitUntil(self.clients.claim())
+})
diff --git a/app/images/safari-share-icon.png b/app/images/safari-share-icon.png
new file mode 100644
index 0000000..0d992ba
Binary files /dev/null and b/app/images/safari-share-icon.png differ
diff --git a/app/install/EnableNotifications.tsx b/app/install/EnableNotifications.tsx
new file mode 100644
index 0000000..aa5a09a
--- /dev/null
+++ b/app/install/EnableNotifications.tsx
@@ -0,0 +1,61 @@
+import React from "react"
+import { useEffect } from "react"
+import { usePush } from "../usePush"
+
+export default function EnableNotifications({
+ onSubscribe,
+}: {
+ onSubscribe: () => void
+}) {
+ return (
+
+
Allow Notifications
+
+ Tack Up Now requires your permission to send notifications in order to
+ function properly
+
+
+
+ )
+}
+
+function EnableButton({ onSubscribe }: { onSubscribe: () => void }) {
+ const { subscribeToPush, requestPermission, canSendPush } = usePush()
+
+ function subscribe() {
+ requestPermission()
+ }
+
+ useEffect(() => {
+ if (!canSendPush) return
+
+ subscribeToPush(
+ urlB64ToUint8Array(applicationServerPublicKey) as any,
+ (subscription) => {
+ fetch("/api/subscribe", {
+ method: "POST",
+ body: JSON.stringify(subscription),
+ })
+ onSubscribe()
+ }
+ )
+ }, [canSendPush])
+
+ return
+}
+
+const applicationServerPublicKey =
+ "BDTbzdtzJxwV0sscdsXla-GKvlcxqQr7edEfkX8-papwvvV1UVc3IMyRacl1BbgTi31nWPji2wKCZkjf1l5iX7Y"
+
+function urlB64ToUint8Array(base64String: string) {
+ const padding = "=".repeat((4 - (base64String.length % 4)) % 4)
+ const base64 = (base64String + padding).replace(/\-/g, "+").replace(/_/g, "/")
+
+ const rawData = window.atob(base64)
+ const outputArray = new Uint8Array(rawData.length)
+
+ for (let i = 0; i < rawData.length; ++i) {
+ outputArray[i] = rawData.charCodeAt(i)
+ }
+ return outputArray
+}
diff --git a/app/install/InstallPWA.tsx b/app/install/InstallPWA.tsx
new file mode 100644
index 0000000..6fa882d
--- /dev/null
+++ b/app/install/InstallPWA.tsx
@@ -0,0 +1,32 @@
+import React from "react"
+
+import shareIcon from "../images/safari-share-icon.png?url"
+
+export default function InstallPWA() {
+ return (
+
+
Install Tack Up Now!
+
+ Install Tack Up Now on your device to get notified when it's time to
+ tack up
+
+
+
+ Tap
+
+ and choose
+ Add to Home Screen
+
+
+ On the next screen, tap
+ Add
+
+
+ Then open
+ Tack Up Now
+ from your home screen
+
+
+
+ )
+}
diff --git a/app/install/InstallPrompts.tsx b/app/install/InstallPrompts.tsx
new file mode 100644
index 0000000..3760af9
--- /dev/null
+++ b/app/install/InstallPrompts.tsx
@@ -0,0 +1,32 @@
+import React from "react"
+import useInstallState from "../useInstallState"
+import EnableNotifications from "./EnableNotifications"
+import OpenSafari from "./OpenSafari"
+import InstallPWA from "./InstallPWA"
+import Unsupported from "./Unsupported"
+
+interface InstallPromptsProps {
+ isMobileSafari: boolean
+ isSupported: boolean
+ notificationsEnabled: boolean
+ onInstallComplete: () => void
+}
+
+export default function InstallPrompts({
+ isSupported,
+ isMobileSafari,
+ onInstallComplete,
+}: InstallPromptsProps) {
+ const { step } = useInstallState({ isSupported, isMobileSafari })
+
+ const steps = {
+ "open safari": ,
+ install: ,
+ "enable notifications": (
+
+ ),
+ unsupported: ,
+ }
+
+ return steps[step as keyof typeof steps] ?? null
+}
diff --git a/app/install/OpenSafari.tsx b/app/install/OpenSafari.tsx
new file mode 100644
index 0000000..d4b3d9c
--- /dev/null
+++ b/app/install/OpenSafari.tsx
@@ -0,0 +1,11 @@
+import React from "react"
+
+export default function OpenSafari() {
+ return (
+
+
This device requires Tack Up Now to be installed using Safari
+
+
Open tackupnow.com in Safari to continue!
+
+ )
+}
diff --git a/app/install/Unsupported.tsx b/app/install/Unsupported.tsx
new file mode 100644
index 0000000..49f87f1
--- /dev/null
+++ b/app/install/Unsupported.tsx
@@ -0,0 +1,22 @@
+import React, { useState } from "react"
+
+export default function Unsupported() {
+ const [showWhy, setShowWhy] = useState(false)
+
+ return (
+
+
{"Sorry :("}
+
+
Your device doesn't support Tack Up Now!
+
+ {showWhy ? (
+
+ iOS 16.3 and under does not support notification delivery through web
+ apps, so Tack Up Now can't send notifications to your device.
+
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/app/root.tsx b/app/root.tsx
index e82f26f..87b156b 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -4,7 +4,13 @@ import {
Outlet,
Scripts,
ScrollRestoration,
-} from "@remix-run/react";
+} from "@remix-run/react"
+import React from "react"
+import { ManifestLink } from "@remix-pwa/sw"
+import { LinksFunction } from "@remix-run/node"
+import styles from "./styles/styles.css?url"
+
+export const links: LinksFunction = () => [{ rel: "stylesheet", href: styles }]
export function Layout({ children }: { children: React.ReactNode }) {
return (
@@ -13,6 +19,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
+
@@ -21,9 +28,9 @@ export function Layout({ children }: { children: React.ReactNode }) {