diff --git a/react-instantsearch-hooks/remix/.eslintrc b/react-instantsearch-hooks/remix/.eslintrc new file mode 100644 index 0000000000..63c8720ab8 --- /dev/null +++ b/react-instantsearch-hooks/remix/.eslintrc @@ -0,0 +1,7 @@ +{ + "extends": ["@remix-run/eslint-config", "@remix-run/eslint-config/node"], + "rules": { + "@typescript-eslint/naming-convention": "off", + "spaced-comment": ["error", "always", { "markers": ["/"] }] + } +} diff --git a/react-instantsearch-hooks/remix/.gitignore b/react-instantsearch-hooks/remix/.gitignore new file mode 100644 index 0000000000..5f2cb537b5 --- /dev/null +++ b/react-instantsearch-hooks/remix/.gitignore @@ -0,0 +1,8 @@ +node_modules + +/.cache +/build +/public/build +.env + +/app/tailwind.css diff --git a/react-instantsearch-hooks/remix/README.md b/react-instantsearch-hooks/remix/README.md new file mode 100644 index 0000000000..9659e785e0 --- /dev/null +++ b/react-instantsearch-hooks/remix/README.md @@ -0,0 +1,53 @@ +# Welcome to Remix! + +- [Remix Docs](https://remix.run/docs) + +## Development + +From your terminal: + +```sh +npm run dev +``` + +This starts your app in development mode, rebuilding assets on file changes. + +## Deployment + +First, build your app for production: + +```sh +npm run build +``` + +Then run the app in production mode: + +```sh +npm start +``` + +Now you'll need to pick a host to deploy it to. + +### DIY + +If you're familiar with deploying node applications, the built-in Remix app server is production-ready. + +Make sure to deploy the output of `remix build` + +- `build/` +- `public/build/` + +### Using a Template + +When you ran `npx create-remix@latest` there were a few choices for hosting. You can run that again to create a new project, then copy over your `app/` folder to the new project that's pre-configured for your target server. + +```sh +cd .. +# create a new project, and pick a pre-configured host +npx create-remix@latest +cd my-new-remix-app +# remove the new project's app (not the old one!) +rm -rf app +# copy your app over +cp -R ../my-old-remix-app/app app +``` diff --git a/react-instantsearch-hooks/remix/app/entry.client.tsx b/react-instantsearch-hooks/remix/app/entry.client.tsx new file mode 100644 index 0000000000..e313b977e5 --- /dev/null +++ b/react-instantsearch-hooks/remix/app/entry.client.tsx @@ -0,0 +1,4 @@ +import { RemixBrowser } from '@remix-run/react'; +import { hydrateRoot } from 'react-dom/client'; + +hydrateRoot(document, ); diff --git a/react-instantsearch-hooks/remix/app/entry.server.tsx b/react-instantsearch-hooks/remix/app/entry.server.tsx new file mode 100644 index 0000000000..9e20196f54 --- /dev/null +++ b/react-instantsearch-hooks/remix/app/entry.server.tsx @@ -0,0 +1,21 @@ +import type { EntryContext } from '@remix-run/node'; +import { RemixServer } from '@remix-run/react'; +import { renderToString } from 'react-dom/server'; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext +) { + const markup = renderToString( + + ); + + responseHeaders.set('Content-Type', 'text/html'); + + return new Response(`${markup}`, { + status: responseStatusCode, + headers: responseHeaders, + }); +} diff --git a/react-instantsearch-hooks/remix/app/root.tsx b/react-instantsearch-hooks/remix/app/root.tsx new file mode 100644 index 0000000000..f7c19e7e52 --- /dev/null +++ b/react-instantsearch-hooks/remix/app/root.tsx @@ -0,0 +1,32 @@ +import type { MetaFunction } from '@remix-run/node'; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from '@remix-run/react'; + +export const meta: MetaFunction = () => ({ + charset: 'utf-8', + title: 'React InstantSearch Hooks - Remix', + viewport: 'width=device-width,initial-scale=1', +}); + +export default function App() { + return ( + + + + + + + + + + + + + ); +} diff --git a/react-instantsearch-hooks/remix/app/routes/index.tsx b/react-instantsearch-hooks/remix/app/routes/index.tsx new file mode 100644 index 0000000000..dff17af99e --- /dev/null +++ b/react-instantsearch-hooks/remix/app/routes/index.tsx @@ -0,0 +1,122 @@ +import algoliasearch from 'algoliasearch/lite'; +import type { InstantSearchServerState } from 'react-instantsearch-hooks-web'; +import { + DynamicWidgets, + Hits, + InstantSearch, + InstantSearchSSRProvider, + Pagination, + RefinementList, + SearchBox, + useInstantSearch, +} from 'react-instantsearch-hooks-web'; +import { getServerState } from 'react-instantsearch-hooks-server'; +import { history } from 'instantsearch.js/cjs/lib/routers/index.js'; +import instantSearchStyles from 'instantsearch.css/themes/satellite-min.css'; + +import type { LinksFunction, LoaderFunction } from '@remix-run/node'; +import { json } from '@remix-run/node'; +import { useLoaderData } from '@remix-run/react'; + +import { Hit } from '../../components/Hit'; +import { Panel } from '../../components/Panel'; +import { ScrollTo } from '../../components/ScrollTo'; +import { NoResultsBoundary } from '../../components/NoResultsBoundary'; +import { SearchErrorToast } from '../../components/SearchErrorToast'; + +import tailwindStyles from '../tailwind.css'; + +const searchClient = algoliasearch( + 'latency', + '6be0576ff61c053d5f9a3225e2a90f76' +); + +export const links: LinksFunction = () => [ + { rel: 'stylesheet', href: instantSearchStyles }, + { rel: 'stylesheet', href: tailwindStyles }, +]; + +export const loader: LoaderFunction = async ({ request }) => { + const serverUrl = request.url; + const serverState = await getServerState(); + + return json({ + serverState, + serverUrl, + }); +}; + +type SearchProps = { + serverState?: InstantSearchServerState; + serverUrl?: string; +}; + +function Search({ serverState, serverUrl }: SearchProps) { + return ( + + + + + +
+ +
+ +
+ + }> + + + +
+
+
+
+ ); +} + +function FallbackComponent({ attribute }: { attribute: string }) { + return ( + + + + ); +} + +function NoResults() { + const { indexUiState } = useInstantSearch(); + + return ( +
+

+ No results for {indexUiState.query}. +

+
+ ); +} + +export default function HomePage() { + const { serverState, serverUrl } = useLoaderData(); + + return ; +} diff --git a/react-instantsearch-hooks/remix/components/Hit.tsx b/react-instantsearch-hooks/remix/components/Hit.tsx new file mode 100644 index 0000000000..07741f6af9 --- /dev/null +++ b/react-instantsearch-hooks/remix/components/Hit.tsx @@ -0,0 +1,31 @@ +import type { Hit as AlgoliaHit } from 'instantsearch.js'; +import { Highlight } from 'react-instantsearch-hooks-web'; + +type HitProps = { + hit: AlgoliaHit<{ + name: string; + price: number; + image: string; + brand: string; + }>; +}; + +export function Hit({ hit }: HitProps) { + return ( +
+
+ {hit.name} +
+

+ + +

+

{hit.brand}

+

${hit.price}

+
+ ); +} diff --git a/react-instantsearch-hooks/remix/components/NoResultsBoundary.tsx b/react-instantsearch-hooks/remix/components/NoResultsBoundary.tsx new file mode 100644 index 0000000000..cf7f6090c1 --- /dev/null +++ b/react-instantsearch-hooks/remix/components/NoResultsBoundary.tsx @@ -0,0 +1,27 @@ +import type { ReactNode } from 'react'; +import { useInstantSearch } from 'react-instantsearch-hooks-web'; + +type NoResultsBoundaryProps = { + children: ReactNode; + fallback: ReactNode; +}; + +export function NoResultsBoundary({ + children, + fallback, +}: NoResultsBoundaryProps) { + const { results } = useInstantSearch(); + + // The `__isArtificial` flag makes sure to not display the No Results message + // when no hits have been returned yet. + if (!results.__isArtificial && results.nbHits === 0) { + return ( + <> + {fallback} + + + ); + } + + return <>{children}; +} diff --git a/react-instantsearch-hooks/remix/components/Panel.tsx b/react-instantsearch-hooks/remix/components/Panel.tsx new file mode 100644 index 0000000000..1fdd24737a --- /dev/null +++ b/react-instantsearch-hooks/remix/components/Panel.tsx @@ -0,0 +1,17 @@ +export function Panel({ + children, + header, + footer, +}: { + children: React.ReactNode; + header?: React.ReactNode; + footer?: React.ReactNode; +}) { + return ( +
+ {header &&
{header}
} +
{children}
+ {footer &&
{footer}
} +
+ ); +} diff --git a/react-instantsearch-hooks/remix/components/ScrollTo.tsx b/react-instantsearch-hooks/remix/components/ScrollTo.tsx new file mode 100644 index 0000000000..2916c42426 --- /dev/null +++ b/react-instantsearch-hooks/remix/components/ScrollTo.tsx @@ -0,0 +1,37 @@ +import type { ComponentProps, ReactNode } from 'react'; +import React, { useEffect, useRef } from 'react'; +import { useInstantSearch } from 'react-instantsearch-hooks-web'; + +type ScrollToProps = ComponentProps<'div'> & { + children: ReactNode; +}; + +export function ScrollTo({ children, ...props }: ScrollToProps) { + const { use } = useInstantSearch(); + const containerRef = useRef(null); + + useEffect(() => { + return use(() => { + return { + onStateChange() { + const isFiltering = document.body.classList.contains('filtering'); + const isTyping = + document.activeElement?.tagName === 'INPUT' && + document.activeElement?.getAttribute('type') === 'search'; + + if (isFiltering || isTyping) { + return; + } + + containerRef.current!.scrollIntoView(); + }, + }; + }); + }, [use]); + + return ( +
+ {children} +
+ ); +} diff --git a/react-instantsearch-hooks/remix/components/SearchErrorToast.tsx b/react-instantsearch-hooks/remix/components/SearchErrorToast.tsx new file mode 100644 index 0000000000..7b7a9cd5a6 --- /dev/null +++ b/react-instantsearch-hooks/remix/components/SearchErrorToast.tsx @@ -0,0 +1,58 @@ +import React, { useEffect, useState } from 'react'; +import * as Toast from '@radix-ui/react-toast'; +import { useInstantSearch } from 'react-instantsearch-hooks-web'; + +export function SearchErrorToast() { + const { use } = useInstantSearch(); + const [error, setError] = useState(null); + + useEffect(() => { + return use(({ instantSearchInstance }) => { + function handleError(searchError: Error) { + setError(searchError); + } + + return { + subscribe() { + instantSearchInstance.addListener('error', handleError); + }, + unsubscribe() { + instantSearchInstance.removeListener('error', handleError); + }, + }; + }); + }, [use]); + + if (!error) { + return null; + } + + return ( + + { + if (!isOpen) { + setError(null); + } + }} + > +
+
+
+
+ + {error.name} + + + {error.message} + +
+
+
+
+
+ + +
+ ); +} diff --git a/react-instantsearch-hooks/remix/package.json b/react-instantsearch-hooks/remix/package.json new file mode 100644 index 0000000000..b2e2f0e2b1 --- /dev/null +++ b/react-instantsearch-hooks/remix/package.json @@ -0,0 +1,39 @@ +{ + "name": "hooks-remix-example", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "run-s \"build:*\"", + "build:css": "yarn run generate:css --minify", + "build:remix": "remix build", + "dev": "run-p \"dev:*\"", + "dev:css": "yarn run generate:css --watch", + "dev:remix": "remix dev", + "generate:css": "npx tailwindcss -o ./app/tailwind.css", + "start": "remix-serve build" + }, + "dependencies": { + "@radix-ui/react-toast": "0.1.1", + "@remix-run/node": "1.6.5", + "@remix-run/react": "1.6.5", + "@remix-run/serve": "1.6.5", + "algoliasearch": "4.11.0", + "instantsearch.css": "7.4.5", + "react": "18.1.0", + "react-dom": "18.1.0", + "react-instantsearch-hooks-server": "6.30.2", + "react-instantsearch-hooks-web": "6.30.2" + }, + "devDependencies": { + "@remix-run/dev": "1.6.5", + "@remix-run/eslint-config": "1.6.5", + "@types/react": "18.0.17", + "@types/react-dom": "18.0.6", + "npm-run-all": "4.1.5", + "tailwindcss": "3.1.6", + "typescript": "4.7.4" + }, + "engines": { + "node": ">=14" + } +} diff --git a/react-instantsearch-hooks/remix/public/favicon.ico b/react-instantsearch-hooks/remix/public/favicon.ico new file mode 100644 index 0000000000..8830cf6821 Binary files /dev/null and b/react-instantsearch-hooks/remix/public/favicon.ico differ diff --git a/react-instantsearch-hooks/remix/remix.config.js b/react-instantsearch-hooks/remix/remix.config.js new file mode 100644 index 0000000000..2eee2ab3f5 --- /dev/null +++ b/react-instantsearch-hooks/remix/remix.config.js @@ -0,0 +1,8 @@ +/** @type {import('@remix-run/dev').AppConfig} */ +module.exports = { + ignoredRouteFiles: ['**/.*'], + // appDirectory: "app", + // assetsBuildDirectory: "public/build", + // serverBuildPath: "build/index.js", + // publicPath: "/build/", +}; diff --git a/react-instantsearch-hooks/remix/remix.env.d.ts b/react-instantsearch-hooks/remix/remix.env.d.ts new file mode 100644 index 0000000000..72e2affe31 --- /dev/null +++ b/react-instantsearch-hooks/remix/remix.env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/react-instantsearch-hooks/remix/sandbox.config.json b/react-instantsearch-hooks/remix/sandbox.config.json new file mode 100644 index 0000000000..be139c9097 --- /dev/null +++ b/react-instantsearch-hooks/remix/sandbox.config.json @@ -0,0 +1,4 @@ +{ + "template": "node", + "container": { "port": 3000, "startScript": "dev" } +} \ No newline at end of file diff --git a/react-instantsearch-hooks/remix/tailwind.config.js b/react-instantsearch-hooks/remix/tailwind.config.js new file mode 100644 index 0000000000..f7b2a44a53 --- /dev/null +++ b/react-instantsearch-hooks/remix/tailwind.config.js @@ -0,0 +1,8 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ['./app/**/*.{ts,tsx,jsx,js}', './components/**/*.{ts,tsx,jsx,js}'], + theme: { + extend: {}, + }, + plugins: [], +}; diff --git a/react-instantsearch-hooks/remix/tsconfig.json b/react-instantsearch-hooks/remix/tsconfig.json new file mode 100644 index 0000000000..20f8a386a6 --- /dev/null +++ b/react-instantsearch-hooks/remix/tsconfig.json @@ -0,0 +1,22 @@ +{ + "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2019"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "moduleResolution": "node", + "resolveJsonModule": true, + "target": "ES2019", + "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + + // Remix takes care of building everything in `remix build`. + "noEmit": true + } +}