diff --git a/app/components/icons/library.tsx b/app/components/icons/library.tsx index 09f8a22ab..57ff9901e 100644 --- a/app/components/icons/library.tsx +++ b/app/components/icons/library.tsx @@ -1622,6 +1622,25 @@ export const ScanIcon = (props: SVGProps) => ( ); +export const InstallIcon = (props: SVGProps) => ( + + + +); + export const ColumnsIcon = (props: SVGProps) => ( (); + const fetcher = useFetcher(); + let optimisticHideInstallPwaPrompt = hideInstallPwaPrompt; + if (fetcher.formData) { + optimisticHideInstallPwaPrompt = + fetcher.formData.get("pwaPromptVisibility") === "hidden"; + } + const hidePwaPromptForm = useRef(null); + + const { promptInstall } = usePwaManager(); + + return optimisticHideInstallPwaPrompt ? null : ( + + +
+ +
+
+

+ Install shelf for mobile +

+

+ Always available access to shelf, with all features you have + on desktop.{" "} + {promptInstall && ( + <> + Use the install button below to add shelf + to your device. + + )} +

+ {promptInstall ? null : ( + <> +
    +
  1. + 1. Click the share icon +
  2. +
  3. + 2. Click "Add to Home Screen" +
  4. +
  5. 3. Enjoy Shelf on your mobile device
  6. +
+ + + + )} +

+ For more information, read the full{" "} + +

+
+ + {promptInstall && ( + + )} + + + + +
+
+
+ + + ); +} diff --git a/app/components/layout/sidebar/menu-items.tsx b/app/components/layout/sidebar/menu-items.tsx index 5ec5638ba..22bea70aa 100644 --- a/app/components/layout/sidebar/menu-items.tsx +++ b/app/components/layout/sidebar/menu-items.tsx @@ -16,7 +16,7 @@ import { useMainMenuItems } from "~/hooks/use-main-menu-items"; import type { loader } from "~/routes/_layout+/_layout"; import { tw } from "~/utils/tw"; import { toggleMobileNavAtom } from "./atoms"; -import { ChatWithAnExpert } from "./chat-with-an-expert"; +import { SidebarNoticeCard } from "./notice-card"; const MenuItems = ({ fetcher }: { fetcher: FetcherWithComponents }) => { const [, toggleMobileNav] = useAtom(toggleMobileNavAtom); @@ -141,14 +141,14 @@ const MenuItems = ({ fetcher }: { fetcher: FetcherWithComponents }) => {
- {/* ChatWithAnExpert component will be visible when uncollapsed sidebar is selected and hidden when minimizing sidebar form is processing */} + {/* Sidebar notice card component will be visible when uncollapsed sidebar is selected and hidden when minimizing sidebar form is processing */} {fetcher.state == "idle" ? ( - + ) : null}
    diff --git a/app/components/layout/sidebar/chat-with-an-expert.tsx b/app/components/layout/sidebar/notice-card.tsx similarity index 57% rename from app/components/layout/sidebar/chat-with-an-expert.tsx rename to app/components/layout/sidebar/notice-card.tsx index 161e7b39f..fed91f4cb 100644 --- a/app/components/layout/sidebar/chat-with-an-expert.tsx +++ b/app/components/layout/sidebar/notice-card.tsx @@ -3,28 +3,28 @@ import { XIcon } from "~/components/icons/library"; import { Button } from "~/components/shared/button"; import type { loader } from "~/routes/_layout+/_layout"; -export const ChatWithAnExpert = () => { - const { hideSupportBanner } = useLoaderData(); +export const SidebarNoticeCard = () => { + const { hideNoticeCard } = useLoaderData(); const fetcher = useFetcher(); - let optimisticHideSupportBanner = hideSupportBanner; + let optimisticHideNoticeCard = hideNoticeCard; if (fetcher.formData) { - optimisticHideSupportBanner = - fetcher.formData.get("bannerVisibility") === "hidden"; + optimisticHideNoticeCard = + fetcher.formData.get("noticeCardVisibility") === "hidden"; } - return optimisticHideSupportBanner ? null : ( + return optimisticHideNoticeCard ? null : (
    - New: Order Asset Labels + Install Shelf for Mobile
    - + @@ -33,9 +33,7 @@ export const ChatWithAnExpert = () => {

    - We are happy to announce that we have the infrastructure to produce - custom branded labels for your business. Affordable rates, fast - turnaround, global shipping, various materials. + Always available access to shelf, with all features you have on desktop.

    {

    diff --git a/app/components/shared/icons-map.tsx b/app/components/shared/icons-map.tsx index 371d32a29..9229403eb 100644 --- a/app/components/shared/icons-map.tsx +++ b/app/components/shared/icons-map.tsx @@ -47,6 +47,7 @@ import { ToolIcon, AddTagsIcon, RemoveTagsIcon, + InstallIcon, ColumnsIcon, LockIcon, ImageIcon, @@ -110,6 +111,7 @@ export type IconType = | "scan" | "tool" | "rows" + | "install" | "columns" | "no-permissions" | "image" @@ -175,6 +177,7 @@ export const iconsMap: IconsMap = { scan: , tool: , rows: , + install: , columns: , lock: , image: , diff --git a/app/root.tsx b/app/root.tsx index 1d630ae34..3afd92311 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -32,6 +32,7 @@ import { ClientHintCheck, getClientHint } from "./utils/client-hints"; import { getBrowserEnv } from "./utils/env"; import { data } from "./utils/http.server"; import { useNonce } from "./utils/nonce-provider"; +import { PwaManagerProvider } from "./utils/pwa-manager"; import { splashScreenLinks } from "./utils/splash-screen-links"; export interface RootData { @@ -149,7 +150,9 @@ function App() { icon="tool" /> ) : ( - + + + ); } diff --git a/app/routes/_layout+/_layout.tsx b/app/routes/_layout+/_layout.tsx index 0713d0fcf..89775c750 100644 --- a/app/routes/_layout+/_layout.tsx +++ b/app/routes/_layout+/_layout.tsx @@ -3,9 +3,11 @@ import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node"; import { json, redirect } from "@remix-run/node"; import { Outlet, useLoaderData } from "@remix-run/react"; import { useAtom } from "jotai"; +import { ClientOnly } from "remix-utils/client-only"; import { switchingWorkspaceAtom } from "~/atoms/switching-workspace"; import { ErrorContent } from "~/components/errors"; +import { InstallPwaPromptModal } from "~/components/layout/install-pwa-prompt-modal"; import Sidebar from "~/components/layout/sidebar/sidebar"; import { useCrisp } from "~/components/marketing/crisp"; import { Spinner } from "~/components/shared/spinner"; @@ -16,6 +18,7 @@ import { getSelectedOrganisation } from "~/modules/organization/context.server"; import { getUserByID } from "~/modules/user/service.server"; import styles from "~/styles/layout/index.css?url"; import { + installPwaPromptCookie, initializePerPageCookieOnLayout, setCookie, userPrefs, @@ -75,7 +78,11 @@ export async function loader({ context, request }: LoaderFunctionArgs) { } /** This checks if the perPage value in the user-prefs cookie exists. If it doesnt it sets it to the default value of 20 */ - const cookie = await initializePerPageCookieOnLayout(request); + const userPrefsCookie = await initializePerPageCookieOnLayout(request); + + const cookieHeader = request.headers.get("Cookie"); + const pwaPromptCookie = + (await installPwaPromptCookie.parse(cookieHeader)) || {}; if (!user.onboarded) { return redirect("onboarding"); @@ -97,9 +104,10 @@ export async function loader({ context, request }: LoaderFunctionArgs) { )?.roles, subscription, enablePremium: config.enablePremiumFeatures, - hideSupportBanner: cookie.hideSupportBanner, - minimizedSidebar: cookie.minimizedSidebar, - scannerCameraId: cookie.scannerCameraId as string, + hideNoticeCard: userPrefsCookie.hideNoticeCard, + minimizedSidebar: userPrefsCookie.minimizedSidebar, + scannerCameraId: userPrefsCookie.scannerCameraId, + hideInstallPwaPrompt: pwaPromptCookie.hidden, isAdmin, canUseBookings: canUseBookings(currentOrganization), /** THis is used to disable team organizations when the currentOrg is Team and no subscription is present */ @@ -112,7 +120,7 @@ export async function loader({ context, request }: LoaderFunctionArgs) { }), }), { - headers: [setCookie(await userPrefs.serialize(cookie))], + headers: [setCookie(await userPrefs.serialize(userPrefsCookie))], } ); } catch (cause) { @@ -127,6 +135,13 @@ export default function App() { useLoaderData(); const [workspaceSwitching] = useAtom(switchingWorkspaceAtom); + const renderInstallPwaPromptOnMobile = () => + // returns InstallPwaPromptModal if the device width is lesser than 640px and the app is being accessed from browser not PWA + window.matchMedia("(max-width: 640px)").matches && + !window.matchMedia("(display-mode: standalone)").matches ? ( + + ) : null; + return ( <>
    + + {renderInstallPwaPromptOnMobile} +
    diff --git a/app/routes/api+/hide-pwa-install-prompt.ts b/app/routes/api+/hide-pwa-install-prompt.ts new file mode 100644 index 000000000..579bb41cf --- /dev/null +++ b/app/routes/api+/hide-pwa-install-prompt.ts @@ -0,0 +1,26 @@ +import { type ActionFunctionArgs, json } from "@remix-run/node"; +import { setCookie, installPwaPromptCookie } from "~/utils/cookies.server"; +import { makeShelfError } from "~/utils/error"; +import { data, error } from "~/utils/http.server"; + +export async function action({ context, request }: ActionFunctionArgs) { + const authSession = context.getSession(); + const { userId } = authSession; + + try { + const cookieHeader = request.headers.get("Cookie"); + const cookie = (await installPwaPromptCookie.parse(cookieHeader)) || {}; + const bodyParams = await request.formData(); + + if (bodyParams.get("pwaPromptVisibility") === "hidden") { + cookie.hidden = true; + } + + return json(data({ success: true }), { + headers: [setCookie(await installPwaPromptCookie.serialize(cookie))], + }); + } catch (cause) { + const reason = makeShelfError(cause, { userId }); + return json(error(reason), { status: reason.status }); + } +} diff --git a/app/routes/api+/user.prefs.dismiss-support-banner.ts b/app/routes/api+/user.prefs.dismiss-notice-card.ts similarity index 89% rename from app/routes/api+/user.prefs.dismiss-support-banner.ts rename to app/routes/api+/user.prefs.dismiss-notice-card.ts index 00a0eb940..b76b12c05 100644 --- a/app/routes/api+/user.prefs.dismiss-support-banner.ts +++ b/app/routes/api+/user.prefs.dismiss-notice-card.ts @@ -12,8 +12,8 @@ export async function action({ context, request }: ActionFunctionArgs) { const cookie = (await userPrefs.parse(cookieHeader)) || {}; const bodyParams = await request.formData(); - if (bodyParams.get("bannerVisibility") === "hidden") { - cookie.hideSupportBanner = true; + if (bodyParams.get("noticeCardVisibility") === "hidden") { + cookie.hideNoticeCard = true; } return json(data({ success: true }), { diff --git a/app/routes/api+/user.prefs.hide-install-pwa-prompt-modal.ts b/app/routes/api+/user.prefs.hide-install-pwa-prompt-modal.ts new file mode 100644 index 000000000..1d1644957 --- /dev/null +++ b/app/routes/api+/user.prefs.hide-install-pwa-prompt-modal.ts @@ -0,0 +1,26 @@ +import { type ActionFunctionArgs, json } from "@remix-run/node"; +import { setCookie, userPrefs } from "~/utils/cookies.server"; +import { makeShelfError } from "~/utils/error"; +import { data, error } from "~/utils/http.server"; + +export async function action({ context, request }: ActionFunctionArgs) { + const authSession = context.getSession(); + const { userId } = authSession; + + try { + const cookieHeader = request.headers.get("Cookie"); + const cookie = (await userPrefs.parse(cookieHeader)) || {}; + const bodyParams = await request.formData(); + + if (bodyParams.get("pwaPromptVisibility") === "hidden") { + cookie.hideInstallPwaPrompt = true; + } + + return json(data({ success: true }), { + headers: [setCookie(await userPrefs.serialize(cookie))], + }); + } catch (cause) { + const reason = makeShelfError(cause, { userId }); + return json(error(reason), { status: reason.status }); + } +} diff --git a/app/utils/cookies.server.ts b/app/utils/cookies.server.ts index 3acef29f3..025326bcb 100644 --- a/app/utils/cookies.server.ts +++ b/app/utils/cookies.server.ts @@ -153,3 +153,8 @@ export async function getAdvancedFiltersFromRequest( } return { filters }; } + +/** HIDE PWA INSTALL PROMPT COOKIE */ +export const installPwaPromptCookie = createCookie("hide-pwa-install-prompt", { + maxAge: 60 * 60 * 24 * 14, // two weeks +}); diff --git a/app/utils/pwa-manager.tsx b/app/utils/pwa-manager.tsx new file mode 100644 index 000000000..293e8df5a --- /dev/null +++ b/app/utils/pwa-manager.tsx @@ -0,0 +1,107 @@ +/** + * You will be surprised by the code below. + * + * `beforeinstallprompt` is an event really hard to work with 😵‍💫 + * + * It has to be be **listened only once**, by a unique effect in root.tsx, otherwise it will work badly. + * + * https://developer.mozilla.org/en-US/docs/Web/API/BeforeInstallPromptEvent + */ + +import { + type ReactElement, + createContext, + useContext, + useSyncExternalStore, +} from "react"; + +/** + * This is the most reliable way (I found) to work with the `BeforeInstallPromptEvent` on the browser. + * + * We will implement what I call the 'external store pattern'. + */ +export type UserChoice = { + outcome: "accepted" | "dismissed"; + platform: string; +}; +export type PwaManager = { + promptInstall: null | (() => Promise); +}; + +export interface BeforeInstallPromptEvent extends Event { + platforms: string[]; + prompt: () => Promise; +} + +const PwaManagerContext = createContext(null); + +/** + * Use `BeforeInstallPromptEvent.prompt` to prompt the user to install the PWA. + * If the PWA is already installed by the current browser, `available` will always be false and `prompt` will always be null. + * + * [21/10/2023] + * + * ❌ On Safari and Firefox, `available` will always be false and `prompt` will always be null. + * These the browser does not support prompt to install, `beforeinstallprompt` event is not fired. + * https://developer.mozilla.org/en-US/docs/Web/API/BeforeInstallPromptEvent#browser_compatibility + * + * 🤷‍♂️ Arc Browser, even if it's based on Chromium, doesn't support prompt to install. + * `prompt` never moves from pending to resolved. + * + * @returns the BeforeInstallPromptEvent if available + */ +export const usePwaManager = () => { + const context = useContext(PwaManagerContext); + + if (context === null) { + throw new Error(`usePwaManager must be used within a PwaManagerProvider.`); + } + + return context; +}; + +let promptInstallStore: PwaManager["promptInstall"] = null; +let subscribers = new Set<() => void>(); + +// Initialize the event listener immediately +if (typeof window !== "undefined") { + window.addEventListener("beforeinstallprompt", (event: Event) => { + event.preventDefault(); + if (!promptInstallStore) { + promptInstallStore = (event as BeforeInstallPromptEvent).prompt.bind( + event + ); + // Notify all subscribers when we get the prompt + subscribers.forEach((callback) => callback()); + } + }); +} + +function subscribeToBeforeInstallPromptEvent(callback: () => void) { + subscribers.add(callback); + return () => { + subscribers.delete(callback); + }; +} + +export const PwaManagerProvider = ({ + children, +}: { + children: ReactElement; +}) => { + const promptInstall = useSyncExternalStore( + subscribeToBeforeInstallPromptEvent, + () => promptInstallStore, + () => null + ); + + return ( + + {children} + + ); +}; diff --git a/public/static/videos/add-to-home-screen.mp4 b/public/static/videos/add-to-home-screen.mp4 new file mode 100644 index 000000000..7a9d79a75 Binary files /dev/null and b/public/static/videos/add-to-home-screen.mp4 differ