From gropulse-skills
Add a free growth strategy call booking feature to a Gropulse Shopify app. Installs @gropulse/booking-widget, wires shop context from the app loader, adds a dedicated booking page, and adds a floating CTA button in the app shell. Use when the user asks to "add appointment booking", "add the booking widget", "add free strategy call", or "integrate @gropulse/booking-widget".
How this skill is triggered — by the user, by Claude, or both
Slash command
/gropulse-skills:appointment-bookingThe 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:
Adds a "Book a Free Growth Strategy Call" feature to a Gropulse embedded Shopify app:
@gropulse/booking-widget from npmbookingApiUrl, ownerName, email, plan, installedSince, timezone)/app/growth-call page (Polaris Web Components, inline BookingWidget)The widget talks to https://booking.gropulse.com — a deployed AI chat + Cal.com booking API. No backend work is needed in the app.
There are two separate buttons with the same label but completely different roles, appearances, and locations:
position: "fixed", bottom: "24px", left: "24px")app/routes/app.tsx inside AppShell, after <Outlet />/app/growth-call and /app/plan (hidden via isHidden check)/app/growth-call via useNavigate from react-router — onClick={() => navigate("/app/growth-call")}. NOT <Link>.<button> with inline styles. Not <Link>, not <s-button> — Polaris Web Components don't render reliably in fixed-position overlays inside the Shopify Admin iframedisplay: "flex", alignItems: "center", gap: "10px" (icon left, text right)#0d1f3c (dark navy)#ffffff15px500 (medium — inherits system/browser font stack, no explicit fontFamily)0.01emnowrap16px 18pxnone14pxpointer0 2px 12px rgba(0,0,0,0.35)opacity 0.15s ease, transform 0.1s ease0.88 + translateY(-1px) lift; mouse-leave restores opacity 1 + translateY(0) (cast e.currentTarget as HTMLButtonElement)9998 (below Shopify Admin modals at 9999+)width="16" height="16" viewBox="0 0 20 20", fill="none", aria-hidden="true", style={{ flexShrink: 0 }}. Exact paths:
<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"/>
Shapes: rounded calendar body, horizontal header divider, two tick marks above top edge, three dots in lower grid cells./app/growth-call page content, below the benefits listapp/routes/app.growth-call.tsx as <BookingWidget trigger="button" buttonLabel="Book Free Strategy Call" />/app/growth-call@gropulse/booking-widget library — not a raw <button>, not inline styles"@gropulse/booking-widget/styles.css")Do NOT confuse these two. They share the same label but serve different purposes:
ASSUMPTIONS I'M MAKING:
1. Stack is React Router v7 (flat file routes) + @shopify/shopify-app-react-router + Polaris Web Components
2. Root app layout is app/routes/app.tsx with a loader that returns apiKey + shopDomain at minimum
3. Shop model exposes: shopOwner, email, planName, installedAt, ianaTimezone
4. The booking API is already deployed at https://booking.gropulse.com (no backend work needed)
5. BOOKING_API_URL env var is optional — hardcoded default is https://booking.gropulse.com
→ Correct me now or I'll proceed with these.
Before starting, verify:
# Confirm flat file routing is in use
grep -r "flatRoutes" app/routes.ts
# Confirm app.tsx loader exists and returns shopDomain
grep -n "shopDomain" app/routes/app.tsx
# Confirm Shop model has the required fields
grep -n "shopOwner\|ianaTimezone\|installedAt" prisma/schema.prisma
If shopOwner, ianaTimezone, or installedAt are missing from the Prisma schema, stop and ask the user before continuing.
npm install @gropulse/booking-widget
Verify:
node -e "require('@gropulse/booking-widget')" 2>/dev/null && echo OK || echo FAIL
ls node_modules/@gropulse/booking-widget/dist/index.d.ts
Expected: OK and the .d.ts file exists.
Package exports:
import { BookingWidget } from "@gropulse/booking-widget";
import "@gropulse/booking-widget/styles.css";
import { useBookingChat } from "@gropulse/booking-widget"; // low-level hook, only if building custom UI
The app/routes/app.tsx root loader must expose these fields so child routes can read them via useRouteLoaderData:
| Field | Source | Notes |
|---|---|---|
bookingApiUrl | process.env.BOOKING_API_URL | Default (production) URL is https://booking.gropulse.com — env var only needed for local dev override |
ownerName | shop?.shopOwner | String, empty string fallback |
email | shop?.email | String, empty string fallback |
plan | shop?.planName | String, "free" fallback |
installedSince | shop?.installedAt | ISO string, new Date() fallback |
timezone | shop?.ianaTimezone | Optional string (undefined if missing) |
Edit app/routes/app.tsx loader return block:
return {
apiKey: process.env.SHOPIFY_API_KEY || "",
shopDomain: session.shop,
appLanguage: settings?.appLanguage ?? "en",
+ appName: process.env.SHOPIFY_APP_NAME || "GroPulse App",
+ ownerName: shop?.shopOwner ?? "",
+ email: shop?.email ?? "",
+ plan: shop?.planName ?? "free",
+ installedSince: (shop?.installedAt ?? new Date()).toISOString(),
+ timezone: shop?.ianaTimezone ?? undefined,
+ bookingApiUrl: process.env.BOOKING_API_URL ?? "https://booking.gropulse.com",
};
Verify:
npm run typecheck 2>&1 | grep -E "error|warning" | head -20
Expected: no new TypeScript errors.
This is Button 1 — the navigation button visible on every page except /app/growth-call and /app/plan (if that route exists).
In app/routes/app.tsx, update the AppShell component to:
useLocation and useNavigate from react-router/app/growth-callImport line change:
-import { Outlet, useLoaderData, useRouteError } from "react-router";
+import { Outlet, useLoaderData, useLocation, useNavigate, useRouteError } from "react-router";
Inside AppShell function body, before the return:
const navigate = useNavigate();
const location = useLocation();
const isHidden = location.pathname === "/app/growth-call" || location.pathname === "/app/plan";
Inside the JSX, after <Outlet /> and before closing </AppProvider>:
{!isHidden && (
<div style={{ position: "fixed", bottom: "24px", left: "24px", zIndex: 9998 }}>
<button
onClick={() => navigate("/app/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 as HTMLButtonElement).style.opacity = "0.88";
(e.currentTarget as HTMLButtonElement).style.transform = "translateY(-1px)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.opacity = "1";
(e.currentTarget as HTMLButtonElement).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>
Book Free Strategy Call
</button>
</div>
)}
Design decisions:
<button> + useNavigate for programmatic navigation. Not <Link>, not <s-button> — Polaris Web Components don't render reliably in fixed-position overlays inside the Shopify Admin iframe.zIndex: 9998 keeps it below Shopify Admin modals (z-index 9999+)./app/growth-call (redundant with Button 2) and /app/plan (if that route exists — contextually irrelevant there)./app/growth-call to <s-app-nav> — hidden page, access only via this floating button.Verify:
npm run typecheck 2>&1 | grep -E "error" | head -20
Create app/routes/app.growth-call.tsx. Flat file route → maps to /app/growth-call, child of app.tsx layout.
This page contains Button 2 — the in-page BookingWidget trigger that opens the AI booking chat.
Key constraints:
useRouteLoaderData("routes/app")BookingWidget and its CSS here (not in app.tsx)s-page, s-section, s-stack, s-grid, s-paragraph, s-icon)BookingWidget with trigger="button" — this renders a styled button via the widget library, NOT a raw <button>appName in the context object changes per app (passed to the AI, not displayed on page)Exact page content (copy verbatim, do not alter):
| Element | Exact text |
|---|---|
Page heading (s-page heading) | Growth Strategy Call |
Subheading (div bold) | Book a free 30-minute Growth Strategy call |
Subtitle (s-paragraph color="subdued") | A working session with a Gropulse Shopify specialist - no sales pitch, no commitment. |
| Benefit 1 | Get specific, actionable tactics tailored to your store - we already have your context. |
| Benefit 2 | Find the biggest growth lever you're missing - conversion, AOV, retention, or paid acquisition. |
| Benefit 3 | Walk away with a 3-step plan you can ship the same week. |
| Benefit 4 | Talk to someone who's grown 100+ Shopify stores - not a generic consultant. |
| Benefit 5 | Completely free - yours because you're already a Gropulse customer. |
Button label (BookingWidget) | Book Free Strategy Call |
Complete file:
import { useRouteLoaderData } from "react-router";
import { BookingWidget } from "@gropulse/booking-widget";
import "@gropulse/booking-widget/styles.css";
import type { loader } from "~/routes/app";
const BENEFITS = [
"Get specific, actionable tactics tailored to your store - we already have your context.",
"Find the biggest growth lever you're missing - conversion, AOV, retention, or paid acquisition.",
"Walk away with a 3-step plan you can ship the same week.",
"Talk to someone who's grown 100+ Shopify stores - not a generic consultant.",
"Completely free - yours because you're already a Gropulse customer.",
];
export default function GrowthCallPage() {
const data = useRouteLoaderData<typeof loader>("routes/app")!;
return (
<s-page heading="Growth Strategy Call">
<s-section>
<s-stack gap="large">
<s-stack gap="small-100">
<div style={{ fontSize: "16px", fontWeight: "bold" }}>Book a free 30-minute Growth Strategy call</div>
<s-paragraph color="subdued">
A working session with a Gropulse Shopify specialist - no sales pitch, no commitment.
</s-paragraph>
</s-stack>
<s-stack gap="small-200">
{BENEFITS.map((benefit, i) => (
<s-grid key={i} gridTemplateColumns="auto 1fr" gap="small-200" alignItems="start">
<s-icon type="check-circle" tone="success" />
<s-paragraph>{benefit}</s-paragraph>
</s-grid>
))}
</s-stack>
<BookingWidget
apiBaseUrl={data.bookingApiUrl}
context={{
appName: data.appName,
shopDomain: data.shopDomain,
ownerName: data.ownerName,
email: data.email,
plan: data.plan,
installedSince: data.installedSince,
timezone: data.timezone,
}}
trigger="button"
buttonLabel="Book Free Strategy Call"
/>
</s-stack>
</s-section>
</s-page>
);
}
Verify:
npm run typecheck 2>&1 | grep -E "error" | head -20
npm run build 2>&1 | tail -20
The booking URL is hardcoded to https://booking.gropulse.com by default. No env var is required for production.
Add both vars to .env.example to document them:
SHOPIFY_APP_NAME=
# Set to the app's display name — injected into the booking AI system prompt
BOOKING_API_URL=
# Leave blank for production — hardcoded default is https://booking.gropulse.com
# Set to http://localhost:8787 only when running the booking API locally
npm run build
npm run dev
Manual checks:
/app/growth-call/app/growth-call → Button 1 is hidden (no duplicate)/app/growth-call → Button 2 (widget-styled, inside page content) is visible below the benefits listBookingWidget 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 — injected into AI system prompt (set via SHOPIFY_APP_NAME env var)
shopDomain: string; // REQUIRED — e.g. "mystore.myshopify.com"
ownerName: string; // REQUIRED — merchant name for personalization
email: string; // REQUIRED — pre-fills booking attendee email
plan: string; // REQUIRED — e.g. "free", "growth", "scale"
installedSince: string; // REQUIRED — ISO date string
timezone?: string; // OPTIONAL — IANA timezone for slot display
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:
SHOPIFY_APP_NAME env var — injected into AI system prompt via data.appName, no code change neededextraContext fields from the table abovebookingApiUrl pointing to https://booking.gropulse.com — same API for all appsshopOwner, email, planName, installedAt, ianaTimezonePage content is identical across all Gropulse apps. BENEFITS, headings, copy, button label — copy verbatim. Do not change them per app.
| Symptom | Likely cause | Fix |
|---|---|---|
| Widget chat shows error | apiBaseUrl wrong or API down | Check https://booking.gropulse.com/health; verify BOOKING_API_URL env var |
| No slots in chat | Cal.com calendar full or timezone mismatch | Check Cal.com admin; verify timezone is valid IANA string |
TypeScript error on data.bookingApiUrl | Loader field not added | Re-check Step 2 — all 6 fields must be in loader return |
| Floating button (Button 1) shows on growth-call or plan page | isHidden check failing | Verify both location.pathname === "/app/growth-call" and === "/app/plan" — check for trailing slash |
| Both buttons visible on growth-call page | Same as above | Button 1 should be hidden; only Button 2 (widget) shows on that page |
| Widget styles broken | CSS not imported | Confirm import "@gropulse/booking-widget/styles.css" in app.growth-call.tsx |
useRouteLoaderData returns undefined | Route ID string wrong | Must be exactly "routes/app" — matches app/routes/app.tsx |
This step is always last. Before doing anything, ask the user:
I can see this project has i18next set up (app/locales/ exists).
Should I translate the appointment booking UI?
This will move all hardcoded strings in the floating button and the growth-call page into your locale files.
Reply YES to proceed, NO to skip.
Only continue if the user explicitly says yes.
There are three locations with hardcoded strings to move into locale files:
Button 1 — floating CTA label (in app/routes/app.tsx):
"Book Free Strategy Call" (link text)Growth-call page (app/routes/app.growth-call.tsx):
"Growth Strategy Call""Book a free 30-minute Growth Strategy call""A working session with a Gropulse Shopify specialist - no sales pitch, no commitment.""Book Free Strategy Call"en.jsonAdd a growthCall namespace to app/locales/en.json:
{
"growthCall": {
"pageHeading": "Growth Strategy Call",
"subheading": "Book a free 30-minute Growth Strategy call",
"subtitle": "A working session with a Gropulse Shopify specialist - no sales pitch, no commitment.",
"benefit1": "Get specific, actionable tactics tailored to your store - we already have your context.",
"benefit2": "Find the biggest growth lever you're missing - conversion, AOV, retention, or paid acquisition.",
"benefit3": "Walk away with a 3-step plan you can ship the same week.",
"benefit4": "Talk to someone who's grown 100+ Shopify stores - not a generic consultant.",
"benefit5": "Completely free - yours because you're already a Gropulse customer.",
"ctaButton": "Book Free Strategy Call"
}
}
Mirror these keys into every other language file under app/locales/ — translate each string into the target language.
app/routes/app.tsx (Button 1)Button 1 is in AppShell — add useTranslation and replace the hardcoded label:
+import { useTranslation } from "react-i18next";
function AppShell() {
+ const { t } = useTranslation();
const location = useLocation();
const isHidden = location.pathname === "/app/growth-call" || location.pathname === "/app/plan";
// ...
- Book Free Strategy Call
+ {t("growthCall.ctaButton")}
app/routes/app.growth-call.tsx (page + Button 2)Replace all hardcoded strings with t() calls. The BENEFITS array becomes translation keys:
+import { useTranslation } from "react-i18next";
+const BENEFIT_KEYS = [
+ "growthCall.benefit1",
+ "growthCall.benefit2",
+ "growthCall.benefit3",
+ "growthCall.benefit4",
+ "growthCall.benefit5",
+] as const;
-const BENEFITS = [
- "Get specific, actionable tactics tailored to your store - we already have your context.",
- "Find the biggest growth lever you're missing - conversion, AOV, retention, or paid acquisition.",
- "Walk away with a 3-step plan you can ship the same week.",
- "Talk to someone who's grown 100+ Shopify stores - not a generic consultant.",
- "Completely free - yours because you're already a Gropulse customer.",
-];
export default function GrowthCallPage() {
+ const { t } = useTranslation();
const data = useRouteLoaderData<typeof loader>("routes/app")!;
return (
- <s-page heading="Growth Strategy Call">
+ <s-page heading={t("growthCall.pageHeading")}>
<s-section>
<s-stack gap="large">
<s-stack gap="small-100">
- <div style={{ fontSize: "16px", fontWeight: "bold" }}>Book a free 30-minute Growth Strategy call</div>
+ <div style={{ fontSize: "16px", fontWeight: "bold" }}>{t("growthCall.subheading")}</div>
<s-paragraph color="subdued">
- A working session with a Gropulse Shopify specialist - no sales pitch, no commitment.
+ {t("growthCall.subtitle")}
</s-paragraph>
</s-stack>
<s-stack gap="small-200">
- {BENEFITS.map((benefit, i) => (
- <s-grid key={i} gridTemplateColumns="auto 1fr" gap="small-200" alignItems="start">
+ {BENEFIT_KEYS.map((key) => (
+ <s-grid key={key} gridTemplateColumns="auto 1fr" gap="small-200" alignItems="start">
<s-icon type="check-circle" tone="success" />
- <s-paragraph>{benefit}</s-paragraph>
+ <s-paragraph>{t(key)}</s-paragraph>
</s-grid>
))}
</s-stack>
<BookingWidget
// ...
- buttonLabel="Book Free Strategy Call"
+ buttonLabel={t("growthCall.ctaButton")}
/>
npm run typecheck 2>&1 | grep -E "error" | head -20
npm run build 2>&1 | tail -20
Manual check: switch app language → floating button label and all growth-call page text update immediately with no page reload.
| File | Change |
|---|---|
package.json | Add @gropulse/booking-widget dependency |
app/routes/app.tsx | Add 6 loader fields; add useLocation/useNavigate; add floating CTA button (Button 1) |
app/routes/app.growth-call.tsx | New file — growth strategy call landing page with BookingWidget (Button 2) |
.env.example | Add BOOKING_API_URL= (optional) |
app/locales/en.json (if translation skill present) | Add growthCall namespace keys |
app/locales/*.json (if translation skill present) | Mirror and translate growthCall keys in all language files |
app/routes/app.tsx (if translation skill present) | Replace hardcoded Button 1 label with t("growthCall.ctaButton") |
app/routes/app.growth-call.tsx (if translation skill present) | Replace all hardcoded strings with t() calls |
Creates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub fuad-hastechit/gropulse-skills --plugin gropulse-skills