Compare commits
3 Commits
784eccb92a
...
987b419018
| Author | SHA1 | Date |
|---|---|---|
|
|
987b419018 | |
|
|
a62fd5ffdb | |
|
|
2e777d3376 |
|
|
@ -3,6 +3,7 @@
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"semi": false,
|
"semi": false,
|
||||||
"singleQuote": false,
|
"singleQuote": false,
|
||||||
|
"maxLineLength": 120,
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
"files": [
|
"files": [
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import type { Response, Request } from "express"
|
||||||
|
|
||||||
|
export function subscribe(req: Request, res: Response) {
|
||||||
|
res.status(200).send()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import React from "react"
|
||||||
|
import { useEffect } from "react"
|
||||||
|
import { usePush } from "remix-pwa-monorepo/packages/push/client/hook"
|
||||||
|
|
||||||
|
export default function EnableNotifications({
|
||||||
|
onSubscribe,
|
||||||
|
}: {
|
||||||
|
onSubscribe: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Allow Notifications</h1>
|
||||||
|
<div>
|
||||||
|
Tack Up Now requires your permission to send notifications in order to
|
||||||
|
function properly
|
||||||
|
</div>
|
||||||
|
<EnableButton onSubscribe={onSubscribe} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <button onClick={subscribe}>Enable Notifications</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
import React from "react"
|
||||||
|
|
||||||
|
import shareIcon from "../../public/images/safari-share-icon.png"
|
||||||
|
|
||||||
|
export default function InstallPWA() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Install Tack Up Now!</h1>
|
||||||
|
<div>
|
||||||
|
Install Tack Up Now on your device to get notified when it's time to
|
||||||
|
tack up
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<ul>
|
||||||
|
<span>Tap </span>
|
||||||
|
<img style={{ width: "2rem" }} src={shareIcon} />
|
||||||
|
<span> and choose </span>
|
||||||
|
<span className="bold">Add to Home Screen</span>
|
||||||
|
</ul>
|
||||||
|
<ul>
|
||||||
|
<span>On the next screen, tap </span>
|
||||||
|
<span className="bold">Add</span>
|
||||||
|
</ul>
|
||||||
|
<ul>
|
||||||
|
<span>Then open </span>
|
||||||
|
<span className="bold">Tack Up Now</span>
|
||||||
|
<span> from your home screen</span>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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": <OpenSafari />,
|
||||||
|
install: <InstallPWA />,
|
||||||
|
"enable notifications": (
|
||||||
|
<EnableNotifications onSubscribe={onInstallComplete} />
|
||||||
|
),
|
||||||
|
unsupported: <Unsupported />,
|
||||||
|
}
|
||||||
|
|
||||||
|
return steps[step as keyof typeof steps] ?? null
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import React from "react"
|
||||||
|
|
||||||
|
export default function OpenSafari() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>This device requires Tack Up Now to be installed using Safari</div>
|
||||||
|
<br />
|
||||||
|
<div>Open tackupnow.com in Safari to continue!</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import React, { useState } from "react"
|
||||||
|
|
||||||
|
export default function Unsupported() {
|
||||||
|
const [showWhy, setShowWhy] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>{"Sorry :("}</h1>
|
||||||
|
<br />
|
||||||
|
<div>Your device doesn't support Tack Up Now!</div>
|
||||||
|
|
||||||
|
{showWhy ? (
|
||||||
|
<div>
|
||||||
|
iOS 16.3 and under does not support notification delivery through web
|
||||||
|
apps, so Tack Up Now can't send notifications to your device.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setShowWhy(true)}>Why not?</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,10 @@ import {
|
||||||
} from "@remix-run/react"
|
} from "@remix-run/react"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import { ManifestLink } from "@remix-pwa/sw"
|
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 }) {
|
export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@ import React, { Suspense, useEffect, useState } from "react"
|
||||||
import coerceSemver from "semver/functions/coerce"
|
import coerceSemver from "semver/functions/coerce"
|
||||||
import versionAtLeast from "semver/functions/gte"
|
import versionAtLeast from "semver/functions/gte"
|
||||||
import UAParser from "ua-parser-js"
|
import UAParser from "ua-parser-js"
|
||||||
import { usePush } from "remix-pwa-monorepo/packages/push/client/hook"
|
import InstallPrompts from "../install/InstallPrompts"
|
||||||
|
import useInstallState from "../useInstallState"
|
||||||
|
|
||||||
export const meta: MetaFunction = () => {
|
export const meta: MetaFunction = () => {
|
||||||
return [
|
return [
|
||||||
|
|
@ -19,101 +20,57 @@ export const meta: MetaFunction = () => {
|
||||||
|
|
||||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
const userAgent = request.headers.get("user-agent")
|
const userAgent = request.headers.get("user-agent")
|
||||||
const os = new UAParser(userAgent ?? "").getOS()
|
const parsedUserAgent = new UAParser(userAgent ?? "")
|
||||||
|
const os = parsedUserAgent.getOS()
|
||||||
|
const isMobileSafari = parsedUserAgent.getBrowser().name !== "Mobile Safari"
|
||||||
const isSupported =
|
const isSupported =
|
||||||
os.name !== "iOS" ||
|
os.name !== "iOS" ||
|
||||||
versionAtLeast(coerceSemver(os.version) ?? "0.0.0", "16.4.0")
|
versionAtLeast(coerceSemver(os.version) ?? "0.0.0", "16.4.0")
|
||||||
|
|
||||||
return json({ isSupported })
|
return json({ isSupported, isMobileSafari })
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const { isSupported } = useLoaderData<typeof loader>()
|
const { isSupported, isMobileSafari } = useLoaderData<typeof loader>()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}>
|
<div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}>
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<LandingMessage isSupported={isSupported} />
|
<LandingMessage
|
||||||
|
isSupported={isSupported}
|
||||||
|
isMobileSafari={isMobileSafari}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LandingMessage({ isSupported }: { isSupported: boolean }) {
|
function LandingMessage({
|
||||||
const isClient = typeof window !== "undefined"
|
isSupported,
|
||||||
const notificationsEnabled =
|
isMobileSafari,
|
||||||
isClient &&
|
}: {
|
||||||
"Notification" in window &&
|
isSupported: boolean
|
||||||
window.Notification.permission === "granted"
|
isMobileSafari: boolean
|
||||||
|
}) {
|
||||||
const isRunningPWA =
|
const { installed } = useInstallState({ isSupported, isMobileSafari })
|
||||||
isClient &&
|
|
||||||
(("standalone" in navigator && (navigator.standalone as boolean)) ||
|
|
||||||
matchMedia("(dislay-mode: standalone)").matches)
|
|
||||||
|
|
||||||
const [rendered, setRendered] = useState(false)
|
const [rendered, setRendered] = useState(false)
|
||||||
const [isInstalled, setIsInstalled] = useState(notificationsEnabled)
|
const [isInstalled, setIsInstalled] = useState(installed)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRendered(true)
|
setRendered(true)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return !isClient || !rendered ? (
|
return !rendered ? (
|
||||||
<div>Loading</div>
|
<div>Loading</div>
|
||||||
) : isInstalled ? (
|
) : isInstalled ? (
|
||||||
<div>Your Notifications</div>
|
<div>Your Notifications</div>
|
||||||
) : isRunningPWA && !notificationsEnabled ? (
|
|
||||||
<EnableButton onSubscribe={() => setIsInstalled(true)} />
|
|
||||||
) : isSupported ? (
|
|
||||||
<div>Install Tack Up Now!</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div>{"Sorry, your device doesn't support Tack Up Now! :("}</div>
|
<InstallPrompts
|
||||||
|
isMobileSafari={isMobileSafari}
|
||||||
|
isSupported={isSupported}
|
||||||
|
notificationsEnabled={false}
|
||||||
|
onInstallComplete={() => setIsInstalled(true)}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function EnableButton({ onSubscribe }: { onSubscribe: () => void }) {
|
|
||||||
const { subscribeToPush, requestPermission, canSendPush } = usePush()
|
|
||||||
|
|
||||||
async function subscribe() {
|
|
||||||
console.log("Hey the thing was clicked wow")
|
|
||||||
requestPermission()
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!canSendPush) return
|
|
||||||
|
|
||||||
subscribeToPush(
|
|
||||||
urlB64ToUint8Array(applicationServerPublicKey) as any,
|
|
||||||
(subscription) => {
|
|
||||||
fetch("/subscribe", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(subscription),
|
|
||||||
})
|
|
||||||
onSubscribe()
|
|
||||||
},
|
|
||||||
(error) => {
|
|
||||||
const errorDiv = document.createElement("div")
|
|
||||||
errorDiv.innerText = "Error thingy" + JSON.stringify(error)
|
|
||||||
document.appendChild(errorDiv)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}, [canSendPush])
|
|
||||||
|
|
||||||
return <button onClick={subscribe}>Enable notifications</button>
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
.bold {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { usePush } from "remix-pwa-monorepo/packages/push/client/hook"
|
||||||
|
|
||||||
|
type IOSInstallStep =
|
||||||
|
| "loading"
|
||||||
|
| "install"
|
||||||
|
| "open safari"
|
||||||
|
| "enable notifications"
|
||||||
|
| "unsupported"
|
||||||
|
|
||||||
|
export default function useInstallState({
|
||||||
|
isSupported,
|
||||||
|
isMobileSafari,
|
||||||
|
}: {
|
||||||
|
isSupported: boolean
|
||||||
|
isMobileSafari: boolean
|
||||||
|
}) {
|
||||||
|
const isClient = typeof window !== undefined
|
||||||
|
|
||||||
|
if (!isClient)
|
||||||
|
return {
|
||||||
|
step: isSupported ? "loading" : ("unsupported" as IOSInstallStep),
|
||||||
|
installed: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const { canSendPush } = usePush()
|
||||||
|
|
||||||
|
const notificationsEnabled =
|
||||||
|
("Notification" in window &&
|
||||||
|
window.Notification.permission === "granted") ||
|
||||||
|
canSendPush
|
||||||
|
|
||||||
|
const isRunningPWA =
|
||||||
|
("standalone" in navigator && (navigator.standalone as boolean)) ||
|
||||||
|
matchMedia("(dislay-mode: standalone)").matches
|
||||||
|
|
||||||
|
return {
|
||||||
|
step: !isMobileSafari
|
||||||
|
? "open safari"
|
||||||
|
: !isRunningPWA && isMobileSafari
|
||||||
|
? "install"
|
||||||
|
: !notificationsEnabled
|
||||||
|
? "enable notifications"
|
||||||
|
: (null as IOSInstallStep | null),
|
||||||
|
installed: notificationsEnabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,23 @@ describe("when user is on iOS", () => {
|
||||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1",
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("and the browser is not safari", () => {
|
||||||
|
use({
|
||||||
|
userAgent:
|
||||||
|
"Mozilla/5.0 (iPhone; U; CPU iPhone OS 5_1_1 like Mac OS X; en-gb) AppleWebKit/534.46.0 (KHTML, like Gecko) CriOS/19.0.1084.60 Mobile/9B206 Safari/7534.48.3",
|
||||||
|
})
|
||||||
|
|
||||||
|
test("the user is told they need to install through Safari, because reasons, I guess", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/")
|
||||||
|
|
||||||
|
const installText = await page.getByText(/Safari/)
|
||||||
|
|
||||||
|
await expect(installText).toBeAttached()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("and tack up now is not running as a PWA, they are instructed to install tack up now", async ({
|
test("and tack up now is not running as a PWA, they are instructed to install tack up now", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|
@ -52,7 +69,7 @@ describe("when user is on iOS", () => {
|
||||||
permission: "default",
|
permission: "default",
|
||||||
requestPermissionResult: "granted",
|
requestPermissionResult: "granted",
|
||||||
})
|
})
|
||||||
await page.route("/subscribe", async (route) => {
|
await page.route("/api/subscribe", async (route) => {
|
||||||
await route.fulfill()
|
await route.fulfill()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -62,7 +79,7 @@ describe("when user is on iOS", () => {
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto("/")
|
await page.goto("/")
|
||||||
|
|
||||||
const requestPromise = page.waitForRequest("/subscribe")
|
const requestPromise = page.waitForRequest("/api/subscribe")
|
||||||
await page.getByText(/Enable notifications/).click()
|
await page.getByText(/Enable notifications/).click()
|
||||||
const request = await requestPromise
|
const request = await requestPromise
|
||||||
|
|
||||||
|
|
@ -74,7 +91,7 @@ describe("when user is on iOS", () => {
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto("/")
|
await page.goto("/")
|
||||||
|
|
||||||
const requestPromise = page.waitForRequest("/subscribe")
|
const requestPromise = page.waitForRequest("/api/subscribe")
|
||||||
await page.getByText(/Enable notifications/).click()
|
await page.getByText(/Enable notifications/).click()
|
||||||
await requestPromise
|
await requestPromise
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
|
|
@ -3,10 +3,12 @@ import express from "express"
|
||||||
|
|
||||||
// notice that the result of `remix vite:build` is "just a module"
|
// notice that the result of `remix vite:build` is "just a module"
|
||||||
import * as build from "./build/server/index.js"
|
import * as build from "./build/server/index.js"
|
||||||
|
import { subscribe } from "./api/subscribe.js"
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
app.use(express.static("build/client"))
|
app.use(express.static("build/client"))
|
||||||
app.get("/api", (req, res) => res.send("HI"))
|
app.get("/api", (req, res) => res.send("HI"))
|
||||||
|
app.post("/api/subscribe", subscribe)
|
||||||
|
|
||||||
// and your app is "just a request handler"
|
// and your app is "just a request handler"
|
||||||
app.all("*", createRequestHandler({ build }))
|
app.all("*", createRequestHandler({ build }))
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue