feat: scaffold Phase 0 — Remix app template, CI, Redis/BullMQ, test harness
Bootstraps from Shopify's official shopify-app-template-remix (cloned directly rather than via `shopify app init`, which requires an interactive Partner login unavailable in this session): - Prisma (SQLite dev / Postgres-ready) with baseline Session model + migration - Vitest configured for unit tests, Playwright configured for E2E - Redis client + BullMQ worker skeleton (app/lib/redis.server.ts, jobs/worker.ts) - shopify.app.toml: minimal scopes, GDPR + orders webhooks wired (stub handlers), app proxy config for the future storefront widget - Stripped template-repo-only meta files (CLA, issue templates, demo product page) and replaced CI with a lint+typecheck+test workflow - Bumped @shopify/shopify-app-session-storage-prisma to resolve a duplicate @shopify/shopify-api install that broke typecheck - Dropped the Jest-only ESLint config (template default) since the project standardizes on Vitest per IMPLEMENTATION_PLAN.md Verified: npm install, lint, typecheck, unit tests, prisma migrate dev, and npm run build all pass on Node 22 LTS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
952d274bea
commit
0303eba07a
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.cache
|
||||||
|
build
|
||||||
|
node_modules
|
||||||
15
.editorconfig
Normal file
15
.editorconfig
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
# Markdown syntax specifies that trailing whitespaces can be meaningful,
|
||||||
|
# so let’s not trim those. e.g. 2 trailing spaces = linebreak (<br />)
|
||||||
|
# See https://daringfireball.net/projects/markdown/syntax#p
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
16
.env.example
Normal file
16
.env.example
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# Shopify app credentials (from `shopify app config link` / Partner Dashboard).
|
||||||
|
# Never commit the real .env — this file is the template only.
|
||||||
|
SHOPIFY_API_KEY=
|
||||||
|
SHOPIFY_API_SECRET=
|
||||||
|
SCOPES=read_products,read_customers,read_orders,write_orders,read_locations,read_metaobjects,write_metaobjects,write_cart_transforms,write_delivery_customizations,write_payment_customizations,read_markets,read_locales
|
||||||
|
SHOPIFY_APP_URL=https://replace-with-your-tunnel-url.example.com
|
||||||
|
SHOP_CUSTOM_DOMAIN=
|
||||||
|
|
||||||
|
# App DB (Prisma). SQLite locally, Postgres in staging/prod.
|
||||||
|
DATABASE_URL="file:dev.sqlite"
|
||||||
|
|
||||||
|
# Redis (slot-holds TTL, BullMQ queues).
|
||||||
|
REDIS_URL=redis://127.0.0.1:6379
|
||||||
|
|
||||||
|
# Google Maps (Phase 5 — geocoding, radius/distance eligibility, map display).
|
||||||
|
GOOGLE_MAPS_API_KEY=
|
||||||
6
.eslintignore
Normal file
6
.eslintignore
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
build
|
||||||
|
public/build
|
||||||
|
shopify-app-remix
|
||||||
|
*/*.yml
|
||||||
|
.shopify
|
||||||
8
.eslintrc.cjs
Normal file
8
.eslintrc.cjs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('@types/eslint').Linter.BaseConfig} */
|
||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node", "prettier"],
|
||||||
|
globals: {
|
||||||
|
shopify: "readonly"
|
||||||
|
},
|
||||||
|
};
|
||||||
24
.github/workflows/ci.yml
vendored
Normal file
24
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Lint & Unit Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Generate Prisma client
|
||||||
|
run: npx prisma generate
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
|
- name: Type check
|
||||||
|
run: npm run typecheck
|
||||||
|
- name: Unit tests
|
||||||
|
run: npm test -- --run
|
||||||
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
/.cache
|
||||||
|
/build
|
||||||
|
/app/build
|
||||||
|
/public/build/
|
||||||
|
/public/_dev
|
||||||
|
/app/public/build
|
||||||
|
/prisma/dev.sqlite
|
||||||
|
/prisma/dev.sqlite-journal
|
||||||
|
database.sqlite
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
yarn.lock
|
||||||
|
pnpm-lock.yaml
|
||||||
|
|
||||||
|
/extensions/*/dist
|
||||||
|
|
||||||
|
# Redis dump
|
||||||
|
dump.rdb
|
||||||
|
|
||||||
|
# Ignore shopify files created during app dev
|
||||||
|
.shopify/*
|
||||||
|
.shopify.lock
|
||||||
45
.graphqlrc.ts
Normal file
45
.graphqlrc.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import { ApiVersion } from "@shopify/shopify-api";
|
||||||
|
import { shopifyApiProject, ApiType } from "@shopify/api-codegen-preset";
|
||||||
|
import type { IGraphQLConfig } from "graphql-config";
|
||||||
|
|
||||||
|
function getConfig() {
|
||||||
|
const config: IGraphQLConfig = {
|
||||||
|
projects: {
|
||||||
|
default: shopifyApiProject({
|
||||||
|
apiType: ApiType.Admin,
|
||||||
|
apiVersion: ApiVersion.July25,
|
||||||
|
documents: [
|
||||||
|
"./app/**/*.{js,ts,jsx,tsx}",
|
||||||
|
"./app/.server/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
outputDir: "./app/types",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let extensions: string[] = [];
|
||||||
|
try {
|
||||||
|
extensions = fs.readdirSync("./extensions");
|
||||||
|
} catch {
|
||||||
|
// ignore if no extensions
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of extensions) {
|
||||||
|
const extensionPath = `./extensions/${entry}`;
|
||||||
|
const schema = `${extensionPath}/schema.graphql`;
|
||||||
|
if (!fs.existsSync(schema)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
config.projects[entry] = {
|
||||||
|
schema,
|
||||||
|
documents: [`${extensionPath}/**/*.graphql`],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = getConfig();
|
||||||
|
|
||||||
|
export default config;
|
||||||
2
.npmrc
Normal file
2
.npmrc
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
engine-strict=true
|
||||||
|
@shopify:registry=https://registry.npmjs.org
|
||||||
7
.prettierignore
Normal file
7
.prettierignore
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
package.json
|
||||||
|
.shadowenv.d
|
||||||
|
.vscode
|
||||||
|
node_modules
|
||||||
|
prisma
|
||||||
|
public
|
||||||
|
.shopify
|
||||||
6
.vscode/extensions.json
vendored
Normal file
6
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"graphql.vscode-graphql",
|
||||||
|
"shopify.polaris-for-vscode",
|
||||||
|
]
|
||||||
|
}
|
||||||
8
.vscode/mcp.json
vendored
Normal file
8
.vscode/mcp.json
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"servers": {
|
||||||
|
"shopify-dev-mcp": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@shopify/dev-mcp@latest"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
AGENTS.md
Normal file
5
AGENTS.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# Shopify app development
|
||||||
|
|
||||||
|
This app is scaffolded from a Shopify app template. See the README for framework-specific details.
|
||||||
|
|
||||||
|
Use the [Shopify AI Toolkit](https://shopify.dev/docs/apps/build/ai-toolkit) for all Shopify API and platform work. If missing, install it in the agent host per that page (or `npx skills add Shopify/shopify-ai-toolkit --list` for skill-compatible hosts) — do not add tooling to this repo.
|
||||||
94
CHANGELOG.md
Normal file
94
CHANGELOG.md
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
# @shopify/shopify-app-template-remix
|
||||||
|
|
||||||
|
## 2025.12.11
|
||||||
|
|
||||||
|
- [#1201](https://github.com/Shopify/shopify-app-template-remix/pull/1201) Update `@shopify/shopify-app-remix` to v4.1.0 and `@shopify/shopify-app-session-storage-prisma` to v8.0.0, add refresh token fields (`refreshToken` and `refreshTokenExpires`) to Session model in Prisma schema, and adopt the `expiringOfflineAccessTokens` flag for enhanced security through token rotation. See [expiring vs non-expiring offline tokens](https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/offline-access-tokens#expiring-vs-non-expiring-offline-tokens) for more information.
|
||||||
|
|
||||||
|
## 2025.10.01
|
||||||
|
|
||||||
|
**Remix is now React Router.** As of [React Router v7](https://remix.run/blog/merging-remix-and-react-router), Remix and React Router have merged.
|
||||||
|
|
||||||
|
For new projects, use the **[Shopify App Template - React Router](https://github.com/Shopify/shopify-app-template-react-router)** instead.
|
||||||
|
|
||||||
|
To migrate your existing Remix app, follow the **[migration guide](https://github.com/Shopify/shopify-app-template-react-router/wiki/Upgrading-from-Remix)**.
|
||||||
|
|
||||||
|
## 2025.08.16
|
||||||
|
- [#52](https://github.com/Shopify/shopify-app-template-remix/pull/1153) Use `ApiVersion.July25` rather than `LATEST_API_VERSION` in `.graphqlrc`.
|
||||||
|
|
||||||
|
## 2025.07.07
|
||||||
|
- [#1103](https://github.com/Shopify/shopify-app-template-remix/pull/1086) Remove deprecated .npmrc config values
|
||||||
|
|
||||||
|
## 2025.06.12
|
||||||
|
- [#1075](https://github.com/Shopify/shopify-app-template-remix/pull/1075) Add Shopify MCP to [VSCode configs](https://code.visualstudio.com/docs/copilot/chat/mcp-servers#_enable-mcp-support-in-vs-code)
|
||||||
|
|
||||||
|
## 2025.06.12
|
||||||
|
-[#1082](https://github.com/Shopify/shopify-app-template-remix/pull/1082) Remove local Shopify CLI from the template. Developers should use the Shopify CLI [installed globally](https://shopify.dev/docs/api/shopify-cli#installation).
|
||||||
|
## 2025.03.18
|
||||||
|
-[#998](https://github.com/Shopify/shopify-app-template-remix/pull/998) Update to Vite 6
|
||||||
|
|
||||||
|
## 2025.03.01
|
||||||
|
- [#982](https://github.com/Shopify/shopify-app-template-remix/pull/982) Add Shopify Dev Assistant extension to the VSCode extension recommendations
|
||||||
|
|
||||||
|
## 2025.01.31
|
||||||
|
- [#952](https://github.com/Shopify/shopify-app-template-remix/pull/952) Update to Shopify App API v2025-01
|
||||||
|
|
||||||
|
## 2025.01.23
|
||||||
|
|
||||||
|
- [#923](https://github.com/Shopify/shopify-app-template-remix/pull/923) Update `@shopify/shopify-app-session-storage-prisma` to v6.0.0
|
||||||
|
|
||||||
|
## 2025.01.8
|
||||||
|
|
||||||
|
- [#923](https://github.com/Shopify/shopify-app-template-remix/pull/923) Enable GraphQL autocomplete for Javascript
|
||||||
|
|
||||||
|
## 2024.12.19
|
||||||
|
|
||||||
|
- [#904](https://github.com/Shopify/shopify-app-template-remix/pull/904) bump `@shopify/app-bridge-react` to latest
|
||||||
|
-
|
||||||
|
## 2024.12.18
|
||||||
|
|
||||||
|
- [875](https://github.com/Shopify/shopify-app-template-remix/pull/875) Add Scopes Update Webhook
|
||||||
|
## 2024.12.05
|
||||||
|
|
||||||
|
- [#910](https://github.com/Shopify/shopify-app-template-remix/pull/910) Install `openssl` in Docker image to fix Prisma (see [#25817](https://github.com/prisma/prisma/issues/25817#issuecomment-2538544254))
|
||||||
|
- [#907](https://github.com/Shopify/shopify-app-template-remix/pull/907) Move `@remix-run/fs-routes` to `dependencies` to fix Docker image build
|
||||||
|
- [#899](https://github.com/Shopify/shopify-app-template-remix/pull/899) Disable v3_singleFetch flag
|
||||||
|
- [#898](https://github.com/Shopify/shopify-app-template-remix/pull/898) Enable the `removeRest` future flag so new apps aren't tempted to use the REST Admin API.
|
||||||
|
|
||||||
|
## 2024.12.04
|
||||||
|
|
||||||
|
- [#891](https://github.com/Shopify/shopify-app-template-remix/pull/891) Enable remix future flags.
|
||||||
|
|
||||||
|
## 2024.11.26
|
||||||
|
- [888](https://github.com/Shopify/shopify-app-template-remix/pull/888) Update restResources version to 2024-10
|
||||||
|
|
||||||
|
## 2024.11.06
|
||||||
|
|
||||||
|
- [881](https://github.com/Shopify/shopify-app-template-remix/pull/881) Update to the productCreate mutation to use the new ProductCreateInput type
|
||||||
|
|
||||||
|
## 2024.10.29
|
||||||
|
|
||||||
|
- [876](https://github.com/Shopify/shopify-app-template-remix/pull/876) Update shopify-app-remix to v3.4.0 and shopify-app-session-storage-prisma to v5.1.5
|
||||||
|
|
||||||
|
## 2024.10.02
|
||||||
|
|
||||||
|
- [863](https://github.com/Shopify/shopify-app-template-remix/pull/863) Update to Shopify App API v2024-10 and shopify-app-remix v3.3.2
|
||||||
|
|
||||||
|
## 2024.09.18
|
||||||
|
|
||||||
|
- [850](https://github.com/Shopify/shopify-app-template-remix/pull/850) Removed "~" import alias
|
||||||
|
|
||||||
|
## 2024.09.17
|
||||||
|
|
||||||
|
- [842](https://github.com/Shopify/shopify-app-template-remix/pull/842) Move webhook processing to individual routes
|
||||||
|
|
||||||
|
## 2024.08.19
|
||||||
|
|
||||||
|
Replaced deprecated `productVariantUpdate` with `productVariantsBulkUpdate`
|
||||||
|
|
||||||
|
## v2024.08.06
|
||||||
|
|
||||||
|
Allow `SHOP_REDACT` webhook to process without admin context
|
||||||
|
|
||||||
|
## v2024.07.16
|
||||||
|
|
||||||
|
Started tracking changes and releases using calver
|
||||||
21
Dockerfile
Normal file
21
Dockerfile
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
FROM node:18-alpine
|
||||||
|
RUN apk add --no-cache openssl
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
|
||||||
|
RUN npm ci --omit=dev && npm cache clean --force
|
||||||
|
# Remove CLI packages since we don't need them in production by default.
|
||||||
|
# Remove this line if you want to run CLI commands in your container.
|
||||||
|
RUN npm remove @shopify/cli
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
CMD ["npm", "run", "docker-start"]
|
||||||
43
README.md
Normal file
43
README.md
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
# Delivery Date & Time
|
||||||
|
|
||||||
|
Shopify app: scheduling for Shipping / Local Delivery / Store Pickup, with
|
||||||
|
date-time slots, capacity intelligence, and **all-plan checkout enforcement**
|
||||||
|
via Cart/Checkout Validation Functions.
|
||||||
|
|
||||||
|
Read **[PRODUCT_STRATEGY.md](./PRODUCT_STRATEGY.md)** (why) and
|
||||||
|
**[IMPLEMENTATION_PLAN.md](./IMPLEMENTATION_PLAN.md)** (how, phased build
|
||||||
|
order) before making changes. [CLAUDE.md](./CLAUDE.md) holds the
|
||||||
|
non-negotiables for AI-assisted work in this repo.
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
cp .env.example .env # fill in Shopify app credentials after linking
|
||||||
|
npx prisma migrate dev # creates prisma/dev.sqlite locally
|
||||||
|
npm run dev # shopify app dev — requires `shopify auth login` first
|
||||||
|
```
|
||||||
|
|
||||||
|
Redis must be running locally for slot-holds/BullMQ (Phase 4+):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
redis-server # or: docker run -p 6379:6379 redis
|
||||||
|
npm run worker # BullMQ worker (jobs/worker.ts)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `npm run dev` | `shopify app dev` — local dev against a dev store |
|
||||||
|
| `npm test` | Vitest unit tests |
|
||||||
|
| `npm run test:e2e` | Playwright E2E |
|
||||||
|
| `npm run lint` / `npm run typecheck` | ESLint / `tsc --noEmit` |
|
||||||
|
| `npx prisma migrate dev` | DB migrations |
|
||||||
|
| `npm run deploy` | `shopify app deploy` — deploy extensions/functions |
|
||||||
|
| `npm run worker` | BullMQ worker (hold-expiry, notifications) |
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Greenfield — Phase 0 (scaffold & CI) complete. See §6 of
|
||||||
|
`IMPLEMENTATION_PLAN.md` for the phased build order and acceptance criteria.
|
||||||
15
app/db.server.ts
Normal file
15
app/db.server.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
var prismaGlobal: PrismaClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
if (!global.prismaGlobal) {
|
||||||
|
global.prismaGlobal = new PrismaClient();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const prisma = global.prismaGlobal ?? new PrismaClient();
|
||||||
|
|
||||||
|
export default prisma;
|
||||||
59
app/entry.server.tsx
Normal file
59
app/entry.server.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { PassThrough } from "stream";
|
||||||
|
import { renderToPipeableStream } from "react-dom/server";
|
||||||
|
import { RemixServer } from "@remix-run/react";
|
||||||
|
import {
|
||||||
|
createReadableStreamFromReadable,
|
||||||
|
type EntryContext,
|
||||||
|
} from "@remix-run/node";
|
||||||
|
import { isbot } from "isbot";
|
||||||
|
import { addDocumentResponseHeaders } from "./shopify.server";
|
||||||
|
|
||||||
|
export const streamTimeout = 5000;
|
||||||
|
|
||||||
|
export default async function handleRequest(
|
||||||
|
request: Request,
|
||||||
|
responseStatusCode: number,
|
||||||
|
responseHeaders: Headers,
|
||||||
|
remixContext: EntryContext
|
||||||
|
) {
|
||||||
|
addDocumentResponseHeaders(request, responseHeaders);
|
||||||
|
const userAgent = request.headers.get("user-agent");
|
||||||
|
const callbackName = isbot(userAgent ?? '')
|
||||||
|
? "onAllReady"
|
||||||
|
: "onShellReady";
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const { pipe, abort } = renderToPipeableStream(
|
||||||
|
<RemixServer
|
||||||
|
context={remixContext}
|
||||||
|
url={request.url}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
[callbackName]: () => {
|
||||||
|
const body = new PassThrough();
|
||||||
|
const stream = createReadableStreamFromReadable(body);
|
||||||
|
|
||||||
|
responseHeaders.set("Content-Type", "text/html");
|
||||||
|
resolve(
|
||||||
|
new Response(stream, {
|
||||||
|
headers: responseHeaders,
|
||||||
|
status: responseStatusCode,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
pipe(body);
|
||||||
|
},
|
||||||
|
onShellError(error) {
|
||||||
|
reject(error);
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
responseStatusCode = 500;
|
||||||
|
console.error(error);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Automatically timeout the React renderer after 6 seconds, which ensures
|
||||||
|
// React has enough time to flush down the rejected boundary contents
|
||||||
|
setTimeout(abort, streamTimeout + 1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
1
app/globals.d.ts
vendored
Normal file
1
app/globals.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
declare module "*.css";
|
||||||
20
app/lib/redis.server.ts
Normal file
20
app/lib/redis.server.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Redis } from "ioredis";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
// eslint-disable-next-line no-var
|
||||||
|
var __redis: Redis | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One shared connection per process (Remix dev reloads the module, so we
|
||||||
|
// cache it on `global` to avoid exhausting Redis connections).
|
||||||
|
export const redis =
|
||||||
|
global.__redis ??
|
||||||
|
new Redis(process.env.REDIS_URL || "redis://127.0.0.1:6379", {
|
||||||
|
maxRetriesPerRequest: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
global.__redis = redis;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default redis;
|
||||||
30
app/root.tsx
Normal file
30
app/root.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import {
|
||||||
|
Links,
|
||||||
|
Meta,
|
||||||
|
Outlet,
|
||||||
|
Scripts,
|
||||||
|
ScrollRestoration,
|
||||||
|
} from "@remix-run/react";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<link rel="preconnect" href="https://cdn.shopify.com/" />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.shopify.com/static/fonts/inter/v4/styles.css"
|
||||||
|
/>
|
||||||
|
<Meta />
|
||||||
|
<Links />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<Outlet />
|
||||||
|
<ScrollRestoration />
|
||||||
|
<Scripts />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
app/routes.ts
Normal file
3
app/routes.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { flatRoutes } from "@remix-run/fs-routes";
|
||||||
|
|
||||||
|
export default flatRoutes();
|
||||||
58
app/routes/_index/route.tsx
Normal file
58
app/routes/_index/route.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||||
|
import { redirect } from "@remix-run/node";
|
||||||
|
import { Form, useLoaderData } from "@remix-run/react";
|
||||||
|
|
||||||
|
import { login } from "../../shopify.server";
|
||||||
|
|
||||||
|
import styles from "./styles.module.css";
|
||||||
|
|
||||||
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
if (url.searchParams.get("shop")) {
|
||||||
|
throw redirect(`/app?${url.searchParams.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { showForm: Boolean(login) };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { showForm } = useLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.index}>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<h1 className={styles.heading}>A short heading about [your app]</h1>
|
||||||
|
<p className={styles.text}>
|
||||||
|
A tagline about [your app] that describes your value proposition.
|
||||||
|
</p>
|
||||||
|
{showForm && (
|
||||||
|
<Form className={styles.form} method="post" action="/auth/login">
|
||||||
|
<label className={styles.label}>
|
||||||
|
<span>Shop domain</span>
|
||||||
|
<input className={styles.input} type="text" name="shop" />
|
||||||
|
<span>e.g: my-shop-domain.myshopify.com</span>
|
||||||
|
</label>
|
||||||
|
<button className={styles.button} type="submit">
|
||||||
|
Log in
|
||||||
|
</button>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
<ul className={styles.list}>
|
||||||
|
<li>
|
||||||
|
<strong>Product feature</strong>. Some detail about your feature and
|
||||||
|
its benefit to your customer.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Product feature</strong>. Some detail about your feature and
|
||||||
|
its benefit to your customer.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Product feature</strong>. Some detail about your feature and
|
||||||
|
its benefit to your customer.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
app/routes/_index/styles.module.css
Normal file
73
app/routes/_index/styles.module.css
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
.index {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading,
|
||||||
|
.text {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: grid;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin: 0 auto;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
max-width: 20rem;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
padding: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
padding: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
padding-top: 3rem;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list > li {
|
||||||
|
max-width: 20rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 50rem) {
|
||||||
|
.list {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list > li {
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/routes/app._index.tsx
Normal file
36
app/routes/app._index.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||||
|
import { Page, Layout, Text, Card, BlockStack, EmptyState } from "@shopify/polaris";
|
||||||
|
import { TitleBar } from "@shopify/app-bridge-react";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
|
||||||
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
|
await authenticate.admin(request);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<TitleBar title="Delivery Date & Time" />
|
||||||
|
<BlockStack gap="500">
|
||||||
|
<Layout>
|
||||||
|
<Layout.Section>
|
||||||
|
<Card>
|
||||||
|
<EmptyState
|
||||||
|
heading="No locations configured yet"
|
||||||
|
action={{ content: "Add a location", url: "/app/locations" }}
|
||||||
|
image="https://cdn.shopify.com/s/files/1/0757/9955/files/empty-state.svg"
|
||||||
|
>
|
||||||
|
<Text as="p" variant="bodyMd">
|
||||||
|
Set up a fulfillment location and its weekly slots to start
|
||||||
|
taking scheduled Shipping, Local Delivery, or Pickup orders.
|
||||||
|
</Text>
|
||||||
|
</EmptyState>
|
||||||
|
</Card>
|
||||||
|
</Layout.Section>
|
||||||
|
</Layout>
|
||||||
|
</BlockStack>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
app/routes/app.tsx
Normal file
40
app/routes/app.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import type { HeadersFunction, LoaderFunctionArgs } from "@remix-run/node";
|
||||||
|
import { Link, Outlet, useLoaderData, useRouteError } from "@remix-run/react";
|
||||||
|
import { boundary } from "@shopify/shopify-app-remix/server";
|
||||||
|
import { AppProvider } from "@shopify/shopify-app-remix/react";
|
||||||
|
import { NavMenu } from "@shopify/app-bridge-react";
|
||||||
|
import polarisStyles from "@shopify/polaris/build/esm/styles.css?url";
|
||||||
|
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
|
||||||
|
export const links = () => [{ rel: "stylesheet", href: polarisStyles }];
|
||||||
|
|
||||||
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
|
await authenticate.admin(request);
|
||||||
|
|
||||||
|
return { apiKey: process.env.SHOPIFY_API_KEY || "" };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { apiKey } = useLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppProvider isEmbeddedApp apiKey={apiKey}>
|
||||||
|
<NavMenu>
|
||||||
|
<Link to="/app" rel="home">
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
</NavMenu>
|
||||||
|
<Outlet />
|
||||||
|
</AppProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shopify needs Remix to catch some thrown responses, so that their headers are included in the response.
|
||||||
|
export function ErrorBoundary() {
|
||||||
|
return boundary.error(useRouteError());
|
||||||
|
}
|
||||||
|
|
||||||
|
export const headers: HeadersFunction = (headersArgs) => {
|
||||||
|
return boundary.headers(headersArgs);
|
||||||
|
};
|
||||||
8
app/routes/auth.$.tsx
Normal file
8
app/routes/auth.$.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
|
||||||
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
|
await authenticate.admin(request);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
16
app/routes/auth.login/error.server.tsx
Normal file
16
app/routes/auth.login/error.server.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import type { LoginError } from "@shopify/shopify-app-remix/server";
|
||||||
|
import { LoginErrorType } from "@shopify/shopify-app-remix/server";
|
||||||
|
|
||||||
|
interface LoginErrorMessage {
|
||||||
|
shop?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loginErrorMessage(loginErrors: LoginError): LoginErrorMessage {
|
||||||
|
if (loginErrors?.shop === LoginErrorType.MissingShop) {
|
||||||
|
return { shop: "Please enter your shop domain to log in" };
|
||||||
|
} else if (loginErrors?.shop === LoginErrorType.InvalidShop) {
|
||||||
|
return { shop: "Please enter a valid shop domain to log in" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
68
app/routes/auth.login/route.tsx
Normal file
68
app/routes/auth.login/route.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
|
||||||
|
import { Form, useActionData, useLoaderData } from "@remix-run/react";
|
||||||
|
import {
|
||||||
|
AppProvider as PolarisAppProvider,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
FormLayout,
|
||||||
|
Page,
|
||||||
|
Text,
|
||||||
|
TextField,
|
||||||
|
} from "@shopify/polaris";
|
||||||
|
import polarisTranslations from "@shopify/polaris/locales/en.json";
|
||||||
|
import polarisStyles from "@shopify/polaris/build/esm/styles.css?url";
|
||||||
|
|
||||||
|
import { login } from "../../shopify.server";
|
||||||
|
|
||||||
|
import { loginErrorMessage } from "./error.server";
|
||||||
|
|
||||||
|
export const links = () => [{ rel: "stylesheet", href: polarisStyles }];
|
||||||
|
|
||||||
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
|
const errors = loginErrorMessage(await login(request));
|
||||||
|
|
||||||
|
return { errors, polarisTranslations };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
|
const errors = loginErrorMessage(await login(request));
|
||||||
|
|
||||||
|
return {
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Auth() {
|
||||||
|
const loaderData = useLoaderData<typeof loader>();
|
||||||
|
const actionData = useActionData<typeof action>();
|
||||||
|
const [shop, setShop] = useState("");
|
||||||
|
const { errors } = actionData || loaderData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PolarisAppProvider i18n={loaderData.polarisTranslations}>
|
||||||
|
<Page>
|
||||||
|
<Card>
|
||||||
|
<Form method="post">
|
||||||
|
<FormLayout>
|
||||||
|
<Text variant="headingMd" as="h2">
|
||||||
|
Log in
|
||||||
|
</Text>
|
||||||
|
<TextField
|
||||||
|
type="text"
|
||||||
|
name="shop"
|
||||||
|
label="Shop domain"
|
||||||
|
helpText="example.myshopify.com"
|
||||||
|
value={shop}
|
||||||
|
onChange={setShop}
|
||||||
|
autoComplete="on"
|
||||||
|
error={errors.shop}
|
||||||
|
/>
|
||||||
|
<Button submit>Log in</Button>
|
||||||
|
</FormLayout>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</Page>
|
||||||
|
</PolarisAppProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
app/routes/webhooks.app.scopes_update.tsx
Normal file
21
app/routes/webhooks.app.scopes_update.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import type { ActionFunctionArgs } from "@remix-run/node";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
import db from "../db.server";
|
||||||
|
|
||||||
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
|
const { payload, session, topic, shop } = await authenticate.webhook(request);
|
||||||
|
console.log(`Received ${topic} webhook for ${shop}`);
|
||||||
|
|
||||||
|
const current = payload.current as string[];
|
||||||
|
if (session) {
|
||||||
|
await db.session.update({
|
||||||
|
where: {
|
||||||
|
id: session.id
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
scope: current.toString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response();
|
||||||
|
};
|
||||||
17
app/routes/webhooks.app.uninstalled.tsx
Normal file
17
app/routes/webhooks.app.uninstalled.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import type { ActionFunctionArgs } from "@remix-run/node";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
import db from "../db.server";
|
||||||
|
|
||||||
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
|
const { shop, session, topic } = await authenticate.webhook(request);
|
||||||
|
|
||||||
|
console.log(`Received ${topic} webhook for ${shop}`);
|
||||||
|
|
||||||
|
// Webhook requests can trigger multiple times and after an app has already been uninstalled.
|
||||||
|
// If this webhook already ran, the session may have been deleted previously.
|
||||||
|
if (session) {
|
||||||
|
await db.session.deleteMany({ where: { shop } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response();
|
||||||
|
};
|
||||||
13
app/routes/webhooks.customers.data_request.tsx
Normal file
13
app/routes/webhooks.customers.data_request.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import type { ActionFunctionArgs } from "@remix-run/node";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
|
||||||
|
// GDPR: a customer (or Shopify on their behalf) requesting their data.
|
||||||
|
// TODO (Phase 8): compile and return the shop's stored Booking/Waitlist/
|
||||||
|
// Reschedule/Notification records for this customer.
|
||||||
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
|
const { shop, topic, payload } = await authenticate.webhook(request);
|
||||||
|
|
||||||
|
console.log(`Received ${topic} webhook for ${shop}`, payload);
|
||||||
|
|
||||||
|
return new Response();
|
||||||
|
};
|
||||||
14
app/routes/webhooks.customers.redact.tsx
Normal file
14
app/routes/webhooks.customers.redact.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import type { ActionFunctionArgs } from "@remix-run/node";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
|
||||||
|
// GDPR: erase a specific customer's data (fires 10 days after a data
|
||||||
|
// erasure request, or 6 months after their last order on the shop).
|
||||||
|
// TODO (Phase 8): purge/anonymize Booking.customerEmail/customerPhone,
|
||||||
|
// Waitlist entries, and Notification payloads for this customer.
|
||||||
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
|
const { shop, topic, payload } = await authenticate.webhook(request);
|
||||||
|
|
||||||
|
console.log(`Received ${topic} webhook for ${shop}`, payload);
|
||||||
|
|
||||||
|
return new Response();
|
||||||
|
};
|
||||||
12
app/routes/webhooks.orders.cancelled.tsx
Normal file
12
app/routes/webhooks.orders.cancelled.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import type { ActionFunctionArgs } from "@remix-run/node";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
|
||||||
|
// TODO (Phase 4): mark the linked Booking cancelled and free its
|
||||||
|
// capacity/resources. See IMPLEMENTATION_PLAN.md §5.2.
|
||||||
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
|
const { shop, topic, payload } = await authenticate.webhook(request);
|
||||||
|
|
||||||
|
console.log(`Received ${topic} webhook for ${shop}`, payload);
|
||||||
|
|
||||||
|
return new Response();
|
||||||
|
};
|
||||||
13
app/routes/webhooks.orders.create.tsx
Normal file
13
app/routes/webhooks.orders.create.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import type { ActionFunctionArgs } from "@remix-run/node";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
|
||||||
|
// TODO (Phase 4): convert the cart's SlotHold into a confirmed Booking,
|
||||||
|
// consume capacity/resources, write the slot back onto the order via
|
||||||
|
// metafield, and release the hold. See IMPLEMENTATION_PLAN.md §5.2.
|
||||||
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
|
const { shop, topic, payload } = await authenticate.webhook(request);
|
||||||
|
|
||||||
|
console.log(`Received ${topic} webhook for ${shop}`, payload);
|
||||||
|
|
||||||
|
return new Response();
|
||||||
|
};
|
||||||
12
app/routes/webhooks.orders.updated.tsx
Normal file
12
app/routes/webhooks.orders.updated.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import type { ActionFunctionArgs } from "@remix-run/node";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
|
||||||
|
// TODO (Phase 4): sync Booking.status changes (e.g. fulfillment status)
|
||||||
|
// back from order updates. See IMPLEMENTATION_PLAN.md §5.2.
|
||||||
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
|
const { shop, topic, payload } = await authenticate.webhook(request);
|
||||||
|
|
||||||
|
console.log(`Received ${topic} webhook for ${shop}`, payload);
|
||||||
|
|
||||||
|
return new Response();
|
||||||
|
};
|
||||||
16
app/routes/webhooks.shop.redact.tsx
Normal file
16
app/routes/webhooks.shop.redact.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import type { ActionFunctionArgs } from "@remix-run/node";
|
||||||
|
import db from "../db.server";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
|
||||||
|
// GDPR: shop uninstalled the app 48 hours ago — erase all shop data.
|
||||||
|
// TODO (Phase 8): once the full schema exists, delete every row scoped to
|
||||||
|
// this shopDomain across Location/SlotTemplate/Booking/etc.
|
||||||
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
|
const { shop, topic, payload } = await authenticate.webhook(request);
|
||||||
|
|
||||||
|
console.log(`Received ${topic} webhook for ${shop}`, payload);
|
||||||
|
|
||||||
|
await db.session.deleteMany({ where: { shop } });
|
||||||
|
|
||||||
|
return new Response();
|
||||||
|
};
|
||||||
35
app/shopify.server.ts
Normal file
35
app/shopify.server.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import "@shopify/shopify-app-remix/adapters/node";
|
||||||
|
import {
|
||||||
|
ApiVersion,
|
||||||
|
AppDistribution,
|
||||||
|
shopifyApp,
|
||||||
|
} from "@shopify/shopify-app-remix/server";
|
||||||
|
import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";
|
||||||
|
import prisma from "./db.server";
|
||||||
|
|
||||||
|
const shopify = shopifyApp({
|
||||||
|
apiKey: process.env.SHOPIFY_API_KEY,
|
||||||
|
apiSecretKey: process.env.SHOPIFY_API_SECRET || "",
|
||||||
|
apiVersion: ApiVersion.January25,
|
||||||
|
scopes: process.env.SCOPES?.split(","),
|
||||||
|
appUrl: process.env.SHOPIFY_APP_URL || "",
|
||||||
|
authPathPrefix: "/auth",
|
||||||
|
sessionStorage: new PrismaSessionStorage(prisma),
|
||||||
|
distribution: AppDistribution.AppStore,
|
||||||
|
future: {
|
||||||
|
unstable_newEmbeddedAuthStrategy: true,
|
||||||
|
expiringOfflineAccessTokens: true,
|
||||||
|
},
|
||||||
|
...(process.env.SHOP_CUSTOM_DOMAIN
|
||||||
|
? { customShopDomains: [process.env.SHOP_CUSTOM_DOMAIN] }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default shopify;
|
||||||
|
export const apiVersion = ApiVersion.January25;
|
||||||
|
export const addDocumentResponseHeaders = shopify.addDocumentResponseHeaders;
|
||||||
|
export const authenticate = shopify.authenticate;
|
||||||
|
export const unauthenticated = shopify.unauthenticated;
|
||||||
|
export const login = shopify.login;
|
||||||
|
export const registerWebhooks = shopify.registerWebhooks;
|
||||||
|
export const sessionStorage = shopify.sessionStorage;
|
||||||
2
env.d.ts
vendored
Normal file
2
env.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
/// <reference types="@remix-run/node" />
|
||||||
0
extensions/.gitkeep
Normal file
0
extensions/.gitkeep
Normal file
27
jobs/worker.ts
Normal file
27
jobs/worker.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { Worker } from "bullmq";
|
||||||
|
import { Redis } from "ioredis";
|
||||||
|
|
||||||
|
// BullMQ needs its own connection (can't share one used for pub/sub or with
|
||||||
|
// maxRetriesPerRequest set to a finite value in some modes) — kept separate
|
||||||
|
// from app/lib/redis.server.ts for that reason.
|
||||||
|
const connection = new Redis(process.env.REDIS_URL || "redis://127.0.0.1:6379", {
|
||||||
|
maxRetriesPerRequest: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO (Phase 4): hold-expiry — release a SlotHold whose TTL has passed.
|
||||||
|
// TODO (Phase 9): notifications — send queued reminder/ETA messages.
|
||||||
|
// TODO (Phase 5+): capacity-recompute — recalculate denormalized capacity
|
||||||
|
// snapshots after config changes.
|
||||||
|
const holdExpiryWorker = new Worker(
|
||||||
|
"hold-expiry",
|
||||||
|
async (job) => {
|
||||||
|
console.log("Processing hold-expiry job", job.id, job.data);
|
||||||
|
},
|
||||||
|
{ connection },
|
||||||
|
);
|
||||||
|
|
||||||
|
holdExpiryWorker.on("failed", (job, err) => {
|
||||||
|
console.error(`hold-expiry job ${job?.id} failed:`, err);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Worker started, listening for jobs...");
|
||||||
18179
package-lock.json
generated
Normal file
18179
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
89
package.json
Normal file
89
package.json
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"name": "delivery-datetime-app",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "remix vite:build",
|
||||||
|
"dev": "shopify app dev",
|
||||||
|
"config:link": "shopify app config link",
|
||||||
|
"generate": "shopify app generate",
|
||||||
|
"deploy": "shopify app deploy",
|
||||||
|
"config:use": "shopify app config use",
|
||||||
|
"env": "shopify app env",
|
||||||
|
"start": "remix-serve ./build/server/index.js",
|
||||||
|
"docker-start": "npm run setup && npm run start",
|
||||||
|
"setup": "prisma generate && prisma migrate deploy",
|
||||||
|
"lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"worker": "tsx jobs/worker.ts",
|
||||||
|
"shopify": "shopify",
|
||||||
|
"prisma": "prisma",
|
||||||
|
"graphql-codegen": "graphql-codegen",
|
||||||
|
"vite": "vite"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19 <22 || >=22.12"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^6.2.1",
|
||||||
|
"@remix-run/dev": "^2.16.1",
|
||||||
|
"@remix-run/fs-routes": "^2.16.1",
|
||||||
|
"@remix-run/node": "^2.16.1",
|
||||||
|
"@remix-run/react": "^2.16.1",
|
||||||
|
"@remix-run/serve": "^2.16.1",
|
||||||
|
"@shopify/app-bridge-react": "^4.1.6",
|
||||||
|
"@shopify/polaris": "^12.0.0",
|
||||||
|
"@shopify/shopify-app-remix": "^4.1.0",
|
||||||
|
"@shopify/shopify-app-session-storage-prisma": "^9.0.1",
|
||||||
|
"bullmq": "^5.34.0",
|
||||||
|
"ioredis": "^5.4.2",
|
||||||
|
"isbot": "^5.1.0",
|
||||||
|
"luxon": "^3.5.0",
|
||||||
|
"prisma": "^6.2.1",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"vite-tsconfig-paths": "^5.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.49.1",
|
||||||
|
"@remix-run/eslint-config": "^2.16.1",
|
||||||
|
"@remix-run/route-config": "^2.16.1",
|
||||||
|
"@shopify/api-codegen-preset": "^1.1.1",
|
||||||
|
"@types/eslint": "^9.6.1",
|
||||||
|
"@types/luxon": "^3.4.2",
|
||||||
|
"@types/node": "^22.2.0",
|
||||||
|
"@types/react": "^18.2.31",
|
||||||
|
"@types/react-dom": "^18.2.14",
|
||||||
|
"eslint": "^8.42.0",
|
||||||
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
"prettier": "^3.2.4",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"vite": "^6.2.2",
|
||||||
|
"vitest": "^2.1.8"
|
||||||
|
},
|
||||||
|
"workspaces": {
|
||||||
|
"packages": [
|
||||||
|
"extensions/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"trustedDependencies": [
|
||||||
|
"@shopify/plugin-cloudflare"
|
||||||
|
],
|
||||||
|
"resolutions": {
|
||||||
|
"@graphql-tools/url-loader": "8.0.16",
|
||||||
|
"@graphql-codegen/client-preset": "4.7.0",
|
||||||
|
"@graphql-codegen/typescript-operations": "4.5.0",
|
||||||
|
"minimatch": "9.0.5",
|
||||||
|
"vite": "^6.2.2"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"@graphql-tools/url-loader": "8.0.16",
|
||||||
|
"@graphql-codegen/client-preset": "4.7.0",
|
||||||
|
"@graphql-codegen/typescript-operations": "4.5.0",
|
||||||
|
"minimatch": "9.0.5",
|
||||||
|
"vite": "^6.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
playwright.config.ts
Normal file
11
playwright.config.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./tests/e2e",
|
||||||
|
fullyParallel: true,
|
||||||
|
reporter: "list",
|
||||||
|
use: {
|
||||||
|
baseURL: process.env.APP_URL || "http://localhost:3000",
|
||||||
|
trace: "on-first-retry",
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Session" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"shop" TEXT NOT NULL,
|
||||||
|
"state" TEXT NOT NULL,
|
||||||
|
"isOnline" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"scope" TEXT,
|
||||||
|
"expires" DATETIME,
|
||||||
|
"accessToken" TEXT NOT NULL,
|
||||||
|
"userId" BIGINT,
|
||||||
|
"firstName" TEXT,
|
||||||
|
"lastName" TEXT,
|
||||||
|
"email" TEXT,
|
||||||
|
"accountOwner" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"locale" TEXT,
|
||||||
|
"collaborator" BOOLEAN DEFAULT false,
|
||||||
|
"emailVerified" BOOLEAN DEFAULT false,
|
||||||
|
"refreshToken" TEXT,
|
||||||
|
"refreshTokenExpires" DATETIME
|
||||||
|
);
|
||||||
34
prisma/schema.prisma
Normal file
34
prisma/schema.prisma
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note that some adapters may set a maximum length for the String type by default, please ensure your strings are long
|
||||||
|
// enough when changing adapters.
|
||||||
|
// See https://www.prisma.io/docs/orm/reference/prisma-schema-reference#string for more information
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
url = "file:dev.sqlite"
|
||||||
|
}
|
||||||
|
|
||||||
|
model Session {
|
||||||
|
id String @id
|
||||||
|
shop String
|
||||||
|
state String
|
||||||
|
isOnline Boolean @default(false)
|
||||||
|
scope String?
|
||||||
|
expires DateTime?
|
||||||
|
accessToken String
|
||||||
|
userId BigInt?
|
||||||
|
firstName String?
|
||||||
|
lastName String?
|
||||||
|
email String?
|
||||||
|
accountOwner Boolean @default(false)
|
||||||
|
locale String?
|
||||||
|
collaborator Boolean? @default(false)
|
||||||
|
emailVerified Boolean? @default(false)
|
||||||
|
refreshToken String?
|
||||||
|
refreshTokenExpires DateTime?
|
||||||
|
}
|
||||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
73
shopify.app.toml
Normal file
73
shopify.app.toml
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
# This file stores configurations for your Shopify app.
|
||||||
|
# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration
|
||||||
|
|
||||||
|
client_id = ""
|
||||||
|
name = "delivery-datetime-app"
|
||||||
|
application_url = "https://replace-with-your-tunnel-url.example.com"
|
||||||
|
embedded = true
|
||||||
|
|
||||||
|
[access_scopes]
|
||||||
|
# Minimum scopes for the v1 (Phase 0-4) feature set. Add more only when a
|
||||||
|
# feature in IMPLEMENTATION_PLAN.md actually needs it.
|
||||||
|
scopes = "read_products,read_customers,read_orders,write_orders,read_locations,read_metaobjects,write_metaobjects,write_cart_transforms,write_delivery_customizations,write_payment_customizations,read_markets,read_locales"
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
redirect_urls = [
|
||||||
|
"https://replace-with-your-tunnel-url.example.com/auth/callback",
|
||||||
|
"https://replace-with-your-tunnel-url.example.com/auth/shopify/callback",
|
||||||
|
"https://replace-with-your-tunnel-url.example.com/api/auth/callback"
|
||||||
|
]
|
||||||
|
|
||||||
|
[webhooks]
|
||||||
|
api_version = "2024-10"
|
||||||
|
|
||||||
|
# Handled by: app/routes/webhooks.app.uninstalled.tsx
|
||||||
|
[[webhooks.subscriptions]]
|
||||||
|
uri = "/webhooks/app/uninstalled"
|
||||||
|
topics = ["app/uninstalled"]
|
||||||
|
|
||||||
|
# Handled by: app/routes/webhooks.app.scopes_update.tsx
|
||||||
|
[[webhooks.subscriptions]]
|
||||||
|
uri = "/webhooks/app/scopes_update"
|
||||||
|
topics = ["app/scopes_update"]
|
||||||
|
|
||||||
|
# Handled by: app/routes/webhooks.orders.create.tsx (Phase 4)
|
||||||
|
[[webhooks.subscriptions]]
|
||||||
|
uri = "/webhooks/orders/create"
|
||||||
|
topics = ["orders/create"]
|
||||||
|
|
||||||
|
# Handled by: app/routes/webhooks.orders.updated.tsx (Phase 4)
|
||||||
|
[[webhooks.subscriptions]]
|
||||||
|
uri = "/webhooks/orders/updated"
|
||||||
|
topics = ["orders/updated"]
|
||||||
|
|
||||||
|
# Handled by: app/routes/webhooks.orders.cancelled.tsx (Phase 4)
|
||||||
|
[[webhooks.subscriptions]]
|
||||||
|
uri = "/webhooks/orders/cancelled"
|
||||||
|
topics = ["orders/cancelled"]
|
||||||
|
|
||||||
|
# Mandatory GDPR compliance topics — required for Built-for-Shopify / public app review.
|
||||||
|
# Handled by: app/routes/webhooks.customers.data_request.tsx
|
||||||
|
[[webhooks.subscriptions]]
|
||||||
|
uri = "/webhooks/customers/data_request"
|
||||||
|
compliance_topics = ["customers/data_request"]
|
||||||
|
|
||||||
|
# Handled by: app/routes/webhooks.customers.redact.tsx
|
||||||
|
[[webhooks.subscriptions]]
|
||||||
|
uri = "/webhooks/customers/redact"
|
||||||
|
compliance_topics = ["customers/redact"]
|
||||||
|
|
||||||
|
# Handled by: app/routes/webhooks.shop.redact.tsx
|
||||||
|
[[webhooks.subscriptions]]
|
||||||
|
uri = "/webhooks/shop/redact"
|
||||||
|
compliance_topics = ["shop/redact"]
|
||||||
|
|
||||||
|
# App proxy so the storefront Theme App Extension can call our backend
|
||||||
|
# without CORS issues (see IMPLEMENTATION_PLAN.md §5.3).
|
||||||
|
[app_proxy]
|
||||||
|
url = "https://replace-with-your-tunnel-url.example.com/apps/scheduling"
|
||||||
|
subpath = "scheduling"
|
||||||
|
prefix = "apps"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
include_config_on_deploy = true
|
||||||
11
shopify.web.toml.liquid
Normal file
11
shopify.web.toml.liquid
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
name = "remix"
|
||||||
|
roles = ["frontend", "backend"]
|
||||||
|
webhooks_path = "/webhooks/app/uninstalled"
|
||||||
|
|
||||||
|
{%- assign exec = dependency_manager | append: ' exec' -%}
|
||||||
|
{%- if dependency_manager == 'yarn' -%}
|
||||||
|
{%- assign exec = 'yarn' -%}
|
||||||
|
{%- endif %}
|
||||||
|
[commands]
|
||||||
|
predev = "{{ exec }} prisma generate"
|
||||||
|
dev = "{{ exec }} prisma migrate deploy && {{ exec }} remix vite:dev"
|
||||||
10
tests/unit/smoke.test.ts
Normal file
10
tests/unit/smoke.test.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
// Phase 0 smoke test — proves the Vitest harness is wired into CI.
|
||||||
|
// Replace/extend with real coverage starting Phase 1 (template seeding)
|
||||||
|
// and Phase 2 (lib/time.ts, services/scheduling.server.ts).
|
||||||
|
describe("test harness", () => {
|
||||||
|
it("runs", () => {
|
||||||
|
expect(1 + 1).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"include": ["env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"removeComments": false,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"target": "ES2022",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"types": ["node"]
|
||||||
|
}
|
||||||
|
}
|
||||||
73
vite.config.ts
Normal file
73
vite.config.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { vitePlugin as remix } from "@remix-run/dev";
|
||||||
|
import { installGlobals } from "@remix-run/node";
|
||||||
|
import { defineConfig, type UserConfig } from "vite";
|
||||||
|
import tsconfigPaths from "vite-tsconfig-paths";
|
||||||
|
|
||||||
|
installGlobals({ nativeFetch: true });
|
||||||
|
|
||||||
|
// Related: https://github.com/remix-run/remix/issues/2835#issuecomment-1144102176
|
||||||
|
// Replace the HOST env var with SHOPIFY_APP_URL so that it doesn't break the remix server. The CLI will eventually
|
||||||
|
// stop passing in HOST, so we can remove this workaround after the next major release.
|
||||||
|
if (
|
||||||
|
process.env.HOST &&
|
||||||
|
(!process.env.SHOPIFY_APP_URL ||
|
||||||
|
process.env.SHOPIFY_APP_URL === process.env.HOST)
|
||||||
|
) {
|
||||||
|
process.env.SHOPIFY_APP_URL = process.env.HOST;
|
||||||
|
delete process.env.HOST;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = new URL(process.env.SHOPIFY_APP_URL || "http://localhost")
|
||||||
|
.hostname;
|
||||||
|
|
||||||
|
let hmrConfig;
|
||||||
|
if (host === "localhost") {
|
||||||
|
hmrConfig = {
|
||||||
|
protocol: "ws",
|
||||||
|
host: "localhost",
|
||||||
|
port: 64999,
|
||||||
|
clientPort: 64999,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
hmrConfig = {
|
||||||
|
protocol: "wss",
|
||||||
|
host: host,
|
||||||
|
port: parseInt(process.env.FRONTEND_PORT!) || 8002,
|
||||||
|
clientPort: 443,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
allowedHosts: [host],
|
||||||
|
cors: {
|
||||||
|
preflightContinue: true,
|
||||||
|
},
|
||||||
|
port: Number(process.env.PORT || 3000),
|
||||||
|
hmr: hmrConfig,
|
||||||
|
fs: {
|
||||||
|
// See https://vitejs.dev/config/server-options.html#server-fs-allow for more information
|
||||||
|
allow: ["app", "node_modules"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
remix({
|
||||||
|
ignoredRouteFiles: ["**/.*"],
|
||||||
|
future: {
|
||||||
|
v3_fetcherPersist: true,
|
||||||
|
v3_relativeSplatPath: true,
|
||||||
|
v3_throwAbortReason: true,
|
||||||
|
v3_lazyRouteDiscovery: true,
|
||||||
|
v3_singleFetch: false,
|
||||||
|
v3_routeConfig: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
tsconfigPaths(),
|
||||||
|
],
|
||||||
|
build: {
|
||||||
|
assetsInlineLimit: 0,
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ["@shopify/app-bridge-react", "@shopify/polaris"],
|
||||||
|
},
|
||||||
|
}) satisfies UserConfig;
|
||||||
10
vitest.config.ts
Normal file
10
vitest.config.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import tsconfigPaths from "vite-tsconfig-paths";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tsconfigPaths()],
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
include: ["tests/unit/**/*.test.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user