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