fix(review): no shop-domain form on /auth/login; test charges on dev stores
- /auth/login no longer asks for a myshopify domain (App Store 2.3.1). With ?shop= it still hands off to OAuth; otherwise it points to Shopify. - Billing API charges are test charges outside production and on partner development stores (shop.plan.partnerDevelopment), so reviewers can pick a paid plan. Real shops are charged as before. - SCREENCAST.md matches the recording. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a5941a5149
commit
6061877ca1
12
app/lib/billing.server.ts
Normal file
12
app/lib/billing.server.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { type AdminGraphql, gql } from "./guard.server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Billing API charges are test charges outside production and on development stores
|
||||||
|
* (including App Store reviewers' stores), which can't take real charges. Real shops pay.
|
||||||
|
*/
|
||||||
|
export async function billingIsTest(admin: AdminGraphql): Promise<boolean> {
|
||||||
|
if (process.env.NODE_ENV !== "production") return true;
|
||||||
|
const d = await gql<{ shop: { plan: { partnerDevelopment: boolean } } }>(admin, `#graphql
|
||||||
|
query DatewiseBillingMode { shop { plan { partnerDevelopment } } }`).catch(() => null);
|
||||||
|
return !!d?.shop.plan.partnerDevelopment;
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ import { Form, useActionData, useLoaderData, useNavigation } from "react-router"
|
|||||||
import prisma from "../db.server";
|
import prisma from "../db.server";
|
||||||
import type { ReminderPolicy } from "../engine/types";
|
import type { ReminderPolicy } from "../engine/types";
|
||||||
import { adminContext, formBool, formInt, formString } from "../lib/admin.server";
|
import { adminContext, formBool, formInt, formString } from "../lib/admin.server";
|
||||||
|
import { billingIsTest } from "../lib/billing.server";
|
||||||
import { PLAN_GROWTH, PLAN_PRO } from "../shopify.server";
|
import { PLAN_GROWTH, PLAN_PRO } from "../shopify.server";
|
||||||
import { bumpConfig } from "../services/config.server";
|
import { bumpConfig } from "../services/config.server";
|
||||||
import { encrypt } from "../services/crypto.server";
|
import { encrypt } from "../services/crypto.server";
|
||||||
@ -14,7 +15,7 @@ import { type CustomizationKind, findCustomizations, findGuard, gql, setCustomiz
|
|||||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
const { cfg, admin, billing } = await adminContext(request);
|
const { cfg, admin, billing } = await adminContext(request);
|
||||||
// Keep the stored plan in sync with Shopify billing.
|
// Keep the stored plan in sync with Shopify billing.
|
||||||
const check = await billing.check({ plans: [PLAN_GROWTH, PLAN_PRO], isTest: process.env.NODE_ENV !== "production" });
|
const check = await billing.check({ plans: [PLAN_GROWTH, PLAN_PRO], isTest: await billingIsTest(admin) });
|
||||||
const plan = check.appSubscriptions.find((s) => s.status === "ACTIVE")?.name ?? "free";
|
const plan = check.appSubscriptions.find((s) => s.status === "ACTIVE")?.name ?? "free";
|
||||||
if (plan !== cfg.shop.plan) await prisma.shop.update({ where: { id: cfg.shop.id }, data: { plan } });
|
if (plan !== cfg.shop.plan) await prisma.shop.update({ where: { id: cfg.shop.id }, data: { plan } });
|
||||||
const guard = await findGuard(admin).catch(() => null);
|
const guard = await findGuard(admin).catch(() => null);
|
||||||
@ -53,14 +54,15 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
|
|
||||||
if (intent.startsWith("plan:")) {
|
if (intent.startsWith("plan:")) {
|
||||||
const plan = intent.slice(5);
|
const plan = intent.slice(5);
|
||||||
|
const isTest = await billingIsTest(admin);
|
||||||
if (plan === "free") {
|
if (plan === "free") {
|
||||||
const check = await billing.check({ plans: [PLAN_GROWTH, PLAN_PRO], isTest: process.env.NODE_ENV !== "production" });
|
const check = await billing.check({ plans: [PLAN_GROWTH, PLAN_PRO], isTest });
|
||||||
for (const sub of check.appSubscriptions) await billing.cancel({ subscriptionId: sub.id, isTest: process.env.NODE_ENV !== "production", prorate: true });
|
for (const sub of check.appSubscriptions) await billing.cancel({ subscriptionId: sub.id, isTest, prorate: true });
|
||||||
await prisma.shop.update({ where: { id: cfg.shop.id }, data: { plan: "free" } });
|
await prisma.shop.update({ where: { id: cfg.shop.id }, data: { plan: "free" } });
|
||||||
return { ok: true, message: "Switched to Starter" };
|
return { ok: true, message: "Switched to Starter" };
|
||||||
}
|
}
|
||||||
// Redirects the merchant to Shopify's approval page.
|
// Redirects the merchant to Shopify's approval page.
|
||||||
await billing.request({ plan: plan as typeof PLAN_GROWTH, isTest: process.env.NODE_ENV !== "production", returnUrl: `https://${cfg.shop.domain}/admin/apps/${process.env.SHOPIFY_API_KEY}/app/settings` });
|
await billing.request({ plan: plan as typeof PLAN_GROWTH, isTest, returnUrl: `https://${cfg.shop.domain}/admin/apps/${process.env.SHOPIFY_API_KEY}/app/settings` });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (intent === "guard") {
|
if (intent === "guard") {
|
||||||
|
|||||||
@ -1,31 +1,26 @@
|
|||||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
|
import type { LoaderFunctionArgs } from "react-router";
|
||||||
import { Form, useActionData, useLoaderData } from "react-router";
|
|
||||||
import { LoginErrorType } from "@shopify/shopify-app-react-router/server";
|
|
||||||
import { login } from "../shopify.server";
|
import { login } from "../shopify.server";
|
||||||
|
|
||||||
function message(errors: Awaited<ReturnType<typeof login>> | undefined) {
|
/**
|
||||||
if (!errors || errors instanceof Response) return null;
|
* Datewise is installed and opened from Shopify only (App Store requirement 2.3.1), so there is no
|
||||||
if (errors.shop === LoginErrorType.MissingShop) return "Enter your shop domain to log in";
|
* shop-domain form here. When Shopify sends ?shop=, hand off to OAuth; otherwise explain where to go.
|
||||||
if (errors.shop === LoginErrorType.InvalidShop) return "Enter a valid shop domain, like your-store.myshopify.com";
|
*/
|
||||||
return null;
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
|
if (new URL(request.url).searchParams.get("shop")) {
|
||||||
|
const res = await login(request);
|
||||||
|
if (res instanceof Response) throw res;
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
export const loader = async ({ request }: LoaderFunctionArgs) => ({ error: message(await login(request)) });
|
};
|
||||||
export const action = async ({ request }: ActionFunctionArgs) => ({ error: message(await login(request)) });
|
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const loaderData = useLoaderData<typeof loader>();
|
|
||||||
const actionData = useActionData<typeof action>();
|
|
||||||
const error = actionData?.error ?? loaderData.error;
|
|
||||||
return (
|
return (
|
||||||
<main style={{ maxWidth: 420, margin: "10vh auto", padding: 16, fontFamily: "system-ui, sans-serif" }}>
|
<main style={{ maxWidth: 480, margin: "10vh auto", padding: 16, fontFamily: "system-ui, sans-serif", lineHeight: 1.5 }}>
|
||||||
<h1>Log in to Datewise</h1>
|
<h1>Open Datewise from Shopify</h1>
|
||||||
<Form method="post" style={{ display: "grid", gap: 8 }}>
|
<p>
|
||||||
<label htmlFor="shop">Shop domain</label>
|
Datewise runs inside your Shopify admin. Install it from the Shopify App Store, or open it from
|
||||||
<input id="shop" name="shop" placeholder="your-store.myshopify.com" autoComplete="on" />
|
<strong> Apps</strong> in your Shopify admin if it's already installed.
|
||||||
{error && <p role="alert">{error}</p>}
|
</p>
|
||||||
<button type="submit">Log in</button>
|
|
||||||
</Form>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,20 +8,26 @@ Claude drives the browser and shows an English caption at the bottom of each sce
|
|||||||
| # | Page | Caption |
|
| # | Page | Caption |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 | Shopify admin → Apps → Datewise (Today) | Datewise: Delivery & Pickup. Customers choose store pickup, local delivery or shipping, and a date and time. |
|
| 1 | Shopify admin → Apps → Datewise (Today) | Datewise: Delivery & Pickup. Customers choose store pickup, local delivery or shipping, and a date and time. |
|
||||||
| 2 | Today: setup guide and capacity | The Today page shows a setup guide, today's bookings and capacity for the next 14 days. |
|
| 2 | Today: capacity | The Today page shows today's bookings and capacity for the next 14 days, per location. |
|
||||||
| 3 | Locations & hours → Shop location | Each location has its own hours, time slots, preparation time, cutoffs and orders per slot. |
|
| 3 | Locations & hours → Shop location | Each location has its own hours, time slots, preparation time, cutoffs and orders per slot. |
|
||||||
| 4 | Theme editor: cart block + app embed | Add the scheduler to the cart page and switch on the app embed. This uses a theme app extension. |
|
| 4 | Theme editor: cart block + app embed | Add the scheduler to the cart page and switch on the app embed. This uses a theme app extension. |
|
||||||
| 5 | Datewise Settings | Turn on the checkout guard, show the chosen time at checkout, and hide pay-in-person methods for shipped orders. |
|
| 5 | Datewise Settings | Turn on the checkout guard, show the chosen time at checkout, and hide pay-in-person methods for shipped orders. |
|
||||||
| 6 | Storefront cart | On the storefront, the customer chooses store pickup, a date and a time slot. |
|
| 6 | Storefront cart | On the storefront, the customer chooses store pickup, a date and a time slot. |
|
||||||
| 7 | Cart: confirmation stub | The chosen place is held for 10 minutes while the customer checks out. |
|
| 7 | Cart: confirmation stub | The chosen place is held for 10 minutes while the customer checks out. |
|
||||||
| 8 | Checkout → Pickup | At checkout, the pickup option shows the chosen date and time. |
|
| 8 | Checkout → Pickup | At checkout the customer picks up at the chosen location. Datewise writes the chosen date and time onto the order. |
|
||||||
| 9 | Checkout without a time | Without a chosen time, checkout is blocked. This works on every Shopify plan. |
|
| 9 | Checkout without a time | Without a chosen time, checkout is blocked. This works on every Shopify plan, including Buy it now. |
|
||||||
| 10 | Thank-you page | The order is placed with a test payment. |
|
| 10 | Thank-you page | The order is placed with a test payment. |
|
||||||
| 11 | Datewise → Bookings | The booking appears in Datewise with its date, time, location and customer. |
|
| 11 | Datewise → Bookings | The booking appears in Datewise with its date, time, location and customer. |
|
||||||
| 12 | Shopify order | The order shows the pickup time on the delivery line and in the order details. |
|
| 12 | Shopify order | The order shows the pickup time on the delivery line and in the order details. |
|
||||||
| 13 | Rush control / Settings → plans | Paid plans add rush-day limits, a waitlist, run sheets and SMS reminders. Billing goes through Shopify. |
|
| 13 | Rush control / Settings → plans | Paid plans add rush-day limits, a waitlist, run sheets and SMS reminders. Billing goes through Shopify. |
|
||||||
| 14 | Today | Datewise: the right day, every order. |
|
| 14 | Today | Datewise: the right day, every order. |
|
||||||
|
|
||||||
|
## Recording notes
|
||||||
|
|
||||||
|
- Recorded 2026-10-16: https://youtu.be/VZW767L6bNo (Unlisted). Scene order as recorded: 1–8, 10–12, 9, 13, 14.
|
||||||
|
- Blur the pickup location's address everywhere it appears (Locations list, checkout pickup card, thank-you page map and details). The thank-you page map shows the pin.
|
||||||
|
- Hide the old Metatron-delivery cart widget (`.dd-widget`) if that app is still active on the demo store.
|
||||||
|
|
||||||
## YouTube
|
## YouTube
|
||||||
|
|
||||||
- **Title:** Datewise: Delivery & Pickup for Shopify (app walkthrough)
|
- **Title:** Datewise: Delivery & Pickup for Shopify (app walkthrough)
|
||||||
@ -33,12 +39,12 @@ Claude drives the browser and shows an English caption at the bottom of each sce
|
|||||||
A walkthrough of Datewise: Delivery & Pickup, a Shopify app that lets customers choose store pickup, local delivery or shipping, and a date and time, in the cart.
|
A walkthrough of Datewise: Delivery & Pickup, a Shopify app that lets customers choose store pickup, local delivery or shipping, and a date and time, in the cart.
|
||||||
|
|
||||||
What this video shows:
|
What this video shows:
|
||||||
• Home page with the setup guide and capacity view
|
• Home page with bookings and the capacity view
|
||||||
• Location hours, time slots, preparation time and cutoffs
|
• Location hours, time slots, preparation time and cutoffs
|
||||||
• Adding the scheduler block and app embed in the theme editor
|
• Adding the scheduler block and app embed in the theme editor
|
||||||
• Checkout guard, date and time at checkout, and payment rules
|
• Checkout guard, date and time at checkout, and payment rules
|
||||||
• Choosing a pickup date and time in the cart, with the place held during checkout
|
• Choosing a pickup date and time in the cart, with the place held during checkout
|
||||||
• Checkout showing the chosen time, and checkout blocked when no time is chosen
|
• Pickup at checkout, and checkout blocked when no time is chosen
|
||||||
• The booking in Datewise and the time on the Shopify order
|
• The booking in Datewise and the time on the Shopify order
|
||||||
• Plans, billed through Shopify
|
• Plans, billed through Shopify
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user