From gropulse-skills
Add a free growth strategy call booking feature to a Gropulse Shopify app built with Vite + React (JSX) + Express + Polaris React + react-router-dom + i18next. Installs @gropulse/booking-widget, extends the backend API to expose shop context, adds a dedicated booking page, and adds a floating CTA button in the PlanChecker shell. Use when the user asks to "add appointment booking", "add the booking widget", "add free strategy call", or "integrate @gropulse/booking-widget" on an Express+React app.
How this skill is triggered — by the user, by Claude, or both
Slash command
/gropulse-skills:appointment-booking-express-reactThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Adds a "Book a Free Growth Strategy Call" feature to a Gropulse embedded Shopify app built with **Vite + React (JSX) + Express backend + Polaris React + react-router-dom + i18next**:
Adds a "Book a Free Growth Strategy Call" feature to a Gropulse embedded Shopify app built with Vite + React (JSX) + Express backend + Polaris React + react-router-dom + i18next:
@gropulse/booking-widget from npm (with --legacy-peer-deps)getPlanDetails API to return ownerEmail, ownerName, createdAt, appNamePlanChecker.jsx that navigates to /growth-call/growth-call page (Polaris React + inline BookingWidget)The widget talks to https://booking.gropulse.com — a deployed AI chat + Cal.com booking API.
ASSUMPTIONS I'M MAKING:
1. Stack is Vite + React (JSX, not TSX) + Express backend + Polaris React v13 + react-router-dom
2. File-based routing via import.meta.glob in web/frontend/pages/ (routes have no /app/ prefix)
3. Shop model is Mongoose with fields: shopName, ownerEmail, planName, timestamps: true
4. Backend getPlanDetails controller is at web/backend/controllers/planController.js
5. APP_NAME env var exists in backend env config (web/backend/config/env.config.js)
6. usePlanDetailsQuery hook (react-query) fetches plan data from /api/get_plan_details
7. PlanChecker.jsx is the app shell — wraps children with Frame, has access to useLocation/useNavigate
8. If PlanChecker.jsx already has a floating ContactSupportButton, remove it — it will be replaced by the shared container
9. Project uses i18next with 15 locale files at web/frontend/locales/*/translation.json
10. i18n config uses nsSeparator: false and keySeparator: "." (dot-notation keys like "growthCall.benefit1")
→ Correct me now or I'll proceed with these.
Before starting, verify:
# Confirm file-based routing
grep -n "import.meta.glob" web/frontend/App.jsx
# Confirm PlanChecker exists and uses react-router-dom
grep -n "useNavigate\|useLocation" web/frontend/components/PlanChecker.jsx
# Confirm Shop model has ownerEmail and timestamps
grep -n "ownerEmail\|timestamps" web/backend/models/Shop.js
# Confirm getPlanDetails controller exists
grep -n "getPlanDetails" web/backend/controllers/planController.js
# Confirm i18next is configured
grep -n "i18next" web/frontend/lib/i18n.js
# Confirm APP_NAME env var is defined
grep -n "APP_NAME" web/backend/config/env.config.js
# Check if ContactSupportButton already exists
ls web/frontend/components/common/ContactSupportButton.jsx 2>/dev/null
# Check if PlanChecker already has a floating contact/support button
grep -n "ContactSupport\|position.*fixed" web/frontend/components/PlanChecker.jsx
If ownerEmail is missing from the Shop schema, stop and ask the user before continuing.
If timestamps: true is not on the schema, createdAt won't exist — ask the user.
If PlanChecker.jsx already contains a <ContactSupportButton /> (with or without a position: "fixed" wrapper), you must remove the entire block — both the wrapper div and the component. The shared container added in Step 4c replaces it entirely.
What to look for and remove in PlanChecker.jsx:
Pattern 1 — with a wrapper div:
// REMOVE this entire block
<div style={{ position: "fixed", bottom: "24px", left: "24px", zIndex: 9998 }}>
<ContactSupportButton />
</div>
Pattern 2 — without a wrapper div:
// REMOVE this line
<ContactSupportButton />
Also remove the ContactSupportButton import from PlanChecker.jsx:
- import { ContactSupportButton } from "@/components/common";
Do NOT modify ContactSupportButton.jsx itself — just remove its usage from PlanChecker.jsx. The booking button replaces it in the shared floating container.
cd web/frontend && yarn add @gropulse/booking-widget --legacy-peer-deps
--legacy-peer-deps is required — there's typically a react-query v4/v5 peer dep conflict between @tanstack/react-query and @tanstack/react-query-devtools.
Verify:
ls web/frontend/node_modules/@gropulse/booking-widget/dist/index.js
ls web/frontend/node_modules/@gropulse/booking-widget/dist/styles.css
Package exports:
import { BookingWidget } from "@gropulse/booking-widget";
import "@gropulse/booking-widget/styles.css";
The backend controller must return booking context fields so the frontend usePlanDetailsQuery hook can pass them to BookingWidget.
Edit web/backend/controllers/planController.js — update the getPlanDetails function:
"ownerEmail" and "createdAt" to the Mongoose field projection (second arg of findOne)ownerEmail, ownerName, createdAt, appName to the response JSONownerName from the Shopify REST API with a try/catch fallback export const getPlanDetails = async (req, res) => {
const { shop } = res.locals;
const shopData =
(await Shop.findOne({ shopName: shop }, [
"planName",
+ "ownerEmail",
+ "createdAt",
])) || {};
// ... (plan pricing logic)
+ let ownerName = "";
+ try {
+ const shopInfo = await shopify.shop.get({ fields: ["shop_owner"] });
+ ownerName = shopInfo.shop_owner || "";
+ } catch (_) { /* empty */ }
+
res.status(200).json({
planName: shopData.planName,
- shopUrl: shop,
+ shopName: shop,
ownerEmail: shopData.ownerEmail || "",
+ ownerName,
createdAt: shopData.createdAt
? shopData.createdAt.toISOString()
: new Date().toISOString(),
appName: process.env.APP_NAME || "GroPulse App",
});
};
Notes:
ownerEmail comes from the Mongoose schema field (set during app install)ownerName is fetched from the Shopify API (shopify.shop.get({ fields: ["shop_owner"] })) wrapped in try/catch; falls back to "" if the API call fails or the field is emptycreatedAt comes from timestamps: true on the schema — Mongoose auto-generates itappName comes from APP_NAME env var defined in web/backend/config/env.config.jstimezone is omitted — the Shop model doesn't have an ianaTimezone fieldVerify:
grep -n "ownerEmail\|ownerName\|createdAt\|appName" web/backend/controllers/planController.js
These projects use i18next everywhere — add keys from the start, not as a separate step.
Add a growthCall namespace to every locale file at web/frontend/locales/*/translation.json:
en/translation.json){
"growthCall.pageHeading": "Growth Strategy Call",
"growthCall.subheading": "Book a free 30-minute Growth Strategy call",
"growthCall.subtitle": "A working session with a Gropulse Shopify specialist - no sales pitch, no commitment.",
"growthCall.benefit1": "Get specific, actionable tactics tailored to your store - we already have your context.",
"growthCall.benefit2": "Find the biggest growth lever you're missing - conversion, AOV, retention, or paid acquisition.",
"growthCall.benefit3": "Walk away with a 3-step plan you can ship the same week.",
"growthCall.benefit4": "Talk to someone who's grown 100+ Shopify stores - not a generic consultant.",
"growthCall.benefit5": "Completely free - yours because you're already a Gropulse customer.",
"growthCall.ctaButton": "Book Free Strategy Call"
}
Important: The i18n config uses nsSeparator: false — so keys are flat dot-notation strings like "growthCall.benefit1", NOT nested objects. The key growthCall.benefit1 is stored as a flat key in the JSON, not as a nested { "growthCall": { "benefit1": "..." } } object. HOWEVER, since keySeparator: "." IS set, i18next will interpret the dots as nested paths. So in the JSON file you can use EITHER format:
Flat keys (works because keySeparator: "." resolves them):
{ "growthCall.benefit1": "..." }
Nested object (also works, more readable):
{ "growthCall": { "benefit1": "..." } }
Both work. Use whichever format the existing locale file uses. Check the file first.
All 15 locales: en, de, es, fr, it, ja, pt-BR, pt-PT, nl, hi, fi, el, vi, zh-CN, zh-TW
Page content is identical across all Gropulse apps — translate each string into the target language. Do not change the meaning.
This is Button 1 — the navigation button visible on every page except /growth-call and /plan.
Edit web/frontend/components/PlanChecker.jsx:
import { Frame, Spinner } from "@shopify/polaris";
import { useContext, useEffect } from "react";
import { useLocation, useNavigate } from "react-router-dom";
+ import { useTranslation } from "react-i18next";
import { MainContext } from "@/components/Provider";
import { usePlanDetailsQuery } from "@/hooks/index";
- import { ContactSupportButton } from "@/components/common";
Note: useLocation and useNavigate may already be imported — check first. Also remove the ContactSupportButton import since it's no longer rendered in PlanChecker.
Before adding the new container, check if PlanChecker.jsx already renders a <ContactSupportButton />. If it does, remove the entire block — including any wrapper <div> with position: "fixed".
Remove this pattern (with wrapper):
return (
<PlanCheckerWrapper>
<Frame>
- <div style={{ position: "fixed", bottom: "24px", left: "24px", zIndex: 9998 }}>
- <ContactSupportButton />
- </div>
{children}
</Frame>
</PlanCheckerWrapper>
);
Or remove this pattern (without wrapper):
return (
<PlanCheckerWrapper>
<Frame>
- <ContactSupportButton />
{children}
</Frame>
</PlanCheckerWrapper>
);
The BookingFloatingButton added in Step 4b replaces ContactSupportButton entirely. Do NOT modify ContactSupportButton.jsx — just remove its usage from PlanChecker.jsx.
Add this component in the same file, between PlanCheckerWrapper and the main PlanChecker function:
function BookingFloatingButton() {
const { t } = useTranslation();
const navigate = useNavigate();
return (
<button
onClick={() => navigate("/growth-call")}
style={{
display: "flex",
alignItems: "center",
gap: "10px",
padding: "16px 18px",
fontSize: "15px",
fontWeight: 500,
color: "#ffffff",
background: "#0d1f3c",
border: "none",
borderRadius: "14px",
cursor: "pointer",
boxShadow: "0 2px 12px rgba(0,0,0,0.35)",
letterSpacing: "0.01em",
whiteSpace: "nowrap",
transition: "opacity 0.15s ease, transform 0.1s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = "0.88";
e.currentTarget.style.transform = "translateY(-1px)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = "1";
e.currentTarget.style.transform = "translateY(0)";
}}
>
<svg
width="16"
height="16"
viewBox="0 0 20 20"
fill="none"
aria-hidden="true"
style={{ flexShrink: 0 }}
>
<rect x="3" y="4" width="14" height="14" rx="2.5" stroke="white" strokeWidth="1.6" />
<path d="M3 8h14" stroke="white" strokeWidth="1.6" />
<path d="M7 2v3M13 2v3" stroke="white" strokeWidth="1.6" strokeLinecap="round" />
<circle cx="7" cy="12" r="1" fill="white" />
<circle cx="10" cy="12" r="1" fill="white" />
<circle cx="13" cy="12" r="1" fill="white" />
</svg>
{t("growthCall.ctaButton")}
</button>
);
}
The main PlanChecker function wraps everything. Add the floating container with just the booking button:
function PlanChecker({ children }) {
const location = useLocation();
const isBookingHidden =
location.pathname === "/growth-call" || location.pathname === "/plan";
if (window.location.pathname === "/exitiframe") {
return <>{children}</>;
}
return (
<PlanCheckerWrapper>
<Frame>
<div style={{ paddingBottom: "50px" }}>
{!isBookingHidden && (
<div
style={{
position: "fixed",
bottom: "24px",
left: "24px",
zIndex: 9998,
}}
>
<BookingFloatingButton />
</div>
)}
{children}
</div>
</Frame>
</PlanCheckerWrapper>
);
}
Design decisions:
<button> with useNavigate — Polaris React Button doesn't render reliably in fixed-position overlays inside the Shopify Admin iframeBookingFloatingButton is in the floating container — ContactSupportButton was removed from PlanChecker in Step 4a.1/growth-call (redundant with Button 2) and /plan (contextually irrelevant)paddingBottom: "50px" on the content wrapper prevents overlap with the floating button/app/ prefix in this stack — paths are /growth-call, /plan, etc./growth-call to SideNav — hidden page, access only via the floating buttonCreate web/frontend/pages/growth-call.jsx. File-based routing auto-discovers it as the /growth-call route.
This page contains Button 2 — the in-page BookingWidget trigger that opens the AI booking chat.
Key constraints:
usePlanDetailsQuery hook (react-query)BookingWidget and its CSS herePage, Layout, Card, Text, Icon, BlockStack)CheckIcon from @shopify/polaris-icons for benefit checkmarksBookingWidget with trigger="button" — styled by the widget libraryappName in the context object changes per appComplete file — web/frontend/pages/growth-call.jsx:
import { Page, Layout, Card, Text, Icon, BlockStack } from "@shopify/polaris";
import { CheckIcon } from "@shopify/polaris-icons";
import { useTranslation } from "react-i18next";
import { BookingWidget } from "@gropulse/booking-widget";
import "@gropulse/booking-widget/styles.css";
import { usePlanDetailsQuery } from "@/hooks";
const BENEFIT_KEYS = [
"growthCall.benefit1",
"growthCall.benefit2",
"growthCall.benefit3",
"growthCall.benefit4",
"growthCall.benefit5",
];
export default function GrowthCallPage() {
const { t } = useTranslation();
const { data: planDetails } = usePlanDetailsQuery();
return (
<Page title={t("growthCall.pageHeading")}>
<Layout>
<Layout.Section>
<Card>
<BlockStack gap="400">
<BlockStack gap="100">
<Text variant="headingMd" as="h2">
{t("growthCall.subheading")}
</Text>
<Text tone="subdued" as="p">
{t("growthCall.subtitle")}
</Text>
</BlockStack>
<BlockStack gap="200">
{BENEFIT_KEYS.map((key) => (
<div
key={key}
style={{
display: "grid",
gridTemplateColumns: "auto 1fr",
gap: "12px",
alignItems: "start",
}}
>
<span
style={{
color: "var(--p-color-icon-success)",
display: "flex",
alignItems: "center",
marginTop: "2px",
}}
>
<Icon source={CheckIcon} />
</span>
<Text as="p">{t(key)}</Text>
</div>
))}
</BlockStack>
<BookingWidget
apiBaseUrl="https://booking.gropulse.com"
context={{
appName: planDetails?.appName || "GroPulse App",
shopDomain: planDetails?.shopName || "",
ownerName: planDetails?.ownerName || "",
email: planDetails?.ownerEmail || "",
plan: planDetails?.planName || "free",
installedSince:
planDetails?.createdAt || new Date().toISOString(),
}}
trigger="button"
buttonLabel={t("growthCall.ctaButton")}
/>
</BlockStack>
</Card>
</Layout.Section>
</Layout>
</Page>
);
}
Notes on the context object:
ownerName is fetched from the Shopify API (shopify.shop.get({ fields: ["shop_owner"] })) via the backend; falls back to "" if unavailabletimezone is omitted — Shop model has no ianaTimezone fieldbookingApiUrl is hardcoded to "https://booking.gropulse.com" — no env var needed on frontendplanDetails may be undefined initially (react-query loading) — all fields have fallbacksThe @gropulse/booking-widget renders its chat window right-aligned by default (using .gbw-justify-end → justify-content: flex-end). Add a CSS override to force left alignment so the chat sits flush with the Shopify admin's left edge.
Create web/frontend/css/growth-call.css:
.gbw-fixed.gbw-items-end.gbw-justify-end {
justify-content: flex-start !important;
}
This selector targets the widget's fixed-position chat container and overrides its justify-content from flex-end (right) to flex-start (left).
Import it in web/frontend/css/styles.css:
@import "_wizard.css";
+ @import "growth-call.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
IMPORTANT: The
@importline must end with a semicolon (;). All other imports instyles.csshave it — omitting it on this line is inconsistent and technically invalid CSS.
Verify:
grep -n "growth-call" web/frontend/css/styles.css
ls web/frontend/css/growth-call.css
cd web/frontend && yarn build
If you get an esbuild version mismatch error (Host version X does not match binary version Y), fix with:
rm -rf web/frontend/node_modules/esbuild
cd web/frontend && yarn add esbuild --dev --legacy-peer-deps
yarn build
Verify all files:
# Check all locale files have growthCall keys
for dir in web/frontend/locales/*/; do
lang=$(basename "$dir")
has=$(node -e "const d=require('./$dir/translation.json'); console.log(!!d.growthCall)")
echo "$lang: $has"
done
# Check backend returns new fields
grep -c "ownerEmail\|ownerName\|createdAt\|appName" web/backend/controllers/planController.js
# Check PlanChecker has floating button
grep -c "BookingFloatingButton\|growth-call\|isBookingHidden" web/frontend/components/PlanChecker.jsx
# Check growth-call page exists
ls web/frontend/pages/growth-call.jsx
# Check CSS override exists
ls web/frontend/css/growth-call.css
grep -n "growth-call" web/frontend/css/styles.css
| Attribute | Value |
|---|---|
| Location | Fixed, bottom-left (bottom: 24px, left: 24px) |
| Defined in | web/frontend/components/PlanChecker.jsx → BookingFloatingButton |
| Visible on | Every page except /growth-call and /plan |
| Action | navigate("/growth-call") via react-router-dom |
| Renders as | Raw HTML <button> with inline styles |
| Background | #0d1f3c (dark navy) |
| Text | t("growthCall.ctaButton") → "Book Free Strategy Call" |
| Icon | Calendar SVG (16x16, viewBox 0 0 20 20) |
| z-index | 9998 |
| Hover | opacity → 0.88, translateY(-1px) |
| Attribute | Value |
|---|---|
| Location | Inside /growth-call page content, below benefits list |
| Defined in | web/frontend/pages/growth-call.jsx → <BookingWidget trigger="button"> |
| Visible on | Only /growth-call |
| Action | Opens booking chat widget inline (AI → slots → confirmation) |
| Renders as | Styled by @gropulse/booking-widget/styles.css |
| Label | t("growthCall.ctaButton") → "Book Free Strategy Call" |
BookingWidget propsinterface BookingWidgetProps {
apiBaseUrl: string; // always "https://booking.gropulse.com" in production
context: ShopContext; // merchant + app data for AI personalization
trigger?: "button" | "auto"; // default: "button"
buttonLabel?: string;
onBookingConfirmed?: (booking: BookingInfo) => void;
className?: string;
}
ShopContext fieldsinterface ShopContext {
appName: string; // REQUIRED — from APP_NAME env var via backend API
shopDomain: string; // REQUIRED — from planDetails.shopUrl
ownerName: string; // Fetched from Shopify API; falls back to ""
email: string; // REQUIRED — from planDetails.ownerEmail
plan: string; // REQUIRED — from planDetails.planName
installedSince: string; // REQUIRED — ISO date from planDetails.createdAt
timezone?: string; // OMITTED — Shop model has no ianaTimezone field
extraContext?: Record<string, unknown>; // OPTIONAL — app-specific metrics
}
extraContext by app type// URL Redirects Manager
extraContext: { totalRedirects: number, total404Errors: number, redirectHitsThisMonth: number }
// Reviews app
extraContext: { totalReviews: number, averageRating: number, monthlyOrders: number }
// SEO app
extraContext: { seoScore: number, indexedPages: number, organicTraffic: number }
// Upsell app
extraContext: { upsellRevenue: number, conversionRate: number, activeOffers: number }
// Analytics app
extraContext: { monthlyRevenue: number, returningCustomerRate: number, topChannel: string }
When adding this to a new app with this stack:
APP_NAME env var exists in web/backend/config/env.config.js — it's used for the appName context fieldownerEmail exists and timestamps: true is setextraContext fields from the table above if availableapiBaseUrl hardcoded to "https://booking.gropulse.com" — same API for all appsContactSupportButton from PlanChecker — if it's already rendered there, remove it and its wrapper div; also remove the import. Do NOT modify ContactSupportButton.jsx itself| Symptom | Likely cause | Fix |
|---|---|---|
| Widget chat shows error | apiBaseUrl wrong or API down | Check https://booking.gropulse.com/health |
| No slots in chat | Cal.com calendar full or timezone mismatch | Check Cal.com admin |
| Floating button shows on growth-call page | isBookingHidden check failing | Verify location.pathname === "/growth-call" — no trailing slash, no /app/ prefix |
| Both buttons visible on growth-call page | Same as above | Button 1 hidden, only Button 2 shows |
| Widget styles broken | CSS not imported | Confirm import "@gropulse/booking-widget/styles.css" in growth-call.jsx |
planDetails is undefined | react-query still loading | Fallbacks handle this — ` |
| Backend doesn't return new fields | Controller not updated | Re-check Step 2 — all 4 new fields in response |
| esbuild version mismatch | Stale esbuild binary | rm -rf node_modules/esbuild && yarn add esbuild --dev --legacy-peer-deps |
--legacy-peer-deps needed | react-query v4/v5 conflict | Always use --legacy-peer-deps when installing in these projects |
| File | Change |
|---|---|
web/frontend/package.json | Add @gropulse/booking-widget dependency |
web/backend/controllers/planController.js | Extend getPlanDetails response with ownerEmail, ownerName, createdAt, appName; fetch ownerName from Shopify API |
web/frontend/components/PlanChecker.jsx | Add BookingFloatingButton component; remove existing ContactSupportButton usage and import; add floating container; hide booking button on /growth-call and /plan |
web/frontend/pages/growth-call.jsx | New — growth call landing page with Polaris React + BookingWidget |
web/frontend/css/growth-call.css | New — CSS override to left-align the booking widget chat window |
web/frontend/css/styles.css | Import growth-call.css (semicolon required) |
web/frontend/locales/*/translation.json (15 files) | Add growthCall.* keys in all languages |
This skill (appointment-booking-express-react) is adapted from the appointment-booking skill for a different stack:
| Aspect | appointment-booking (original) | appointment-booking-express-react (this) |
|---|---|---|
| Framework | React Router v7 (flat file routes) | Vite + React + Express |
| Language | TypeScript (.tsx) | JavaScript (.jsx) |
| UI library | Polaris Web Components (s-page, s-stack) | Polaris React (Page, Card, BlockStack, Text) |
| Data loading | Route loaders + useRouteLoaderData | Backend API + usePlanDetailsQuery (react-query) |
| Shop model | Prisma (shopOwner, ianaTimezone, installedAt) | Mongoose (ownerEmail, timestamps.createdAt) |
| Booking context source | Loader return in app/routes/app.tsx | Backend getPlanDetails controller |
| Routes prefix | /app/growth-call | /growth-call |
| Translation | Optional step at end | Built into every step (project always uses i18next) |
| App shell | AppShell in app/routes/app.tsx | PlanChecker in web/frontend/components/PlanChecker.jsx |
| Checkmark icons | <s-icon type="check-circle" tone="success"> | <Icon source={CheckIcon}> from @shopify/polaris-icons |
| Env config | Direct process.env | envalid validated in web/backend/config/env.config.js |
npx claudepluginhub fuad-hastechit/gropulse-skills --plugin gropulse-skillsCreates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.