From 9e01d370872583efe84fc6074487675b0fda3e79 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Sun, 29 Mar 2026 10:01:29 +0000 Subject: [PATCH 1/5] fix(web): set VITE_API_URL= empty for production builds Prevents localhost:3000 from being baked into the production bundle. Vite automatically loads .env.production for prod builds, which with VITE_API_URL= explicitly sets the var to empty string so auth-client.ts uses relative URLs (?? "" fallback). Co-Authored-By: Paperclip --- apps/web/.env.production | 1 + 1 file changed, 1 insertion(+) create mode 100644 apps/web/.env.production diff --git a/apps/web/.env.production b/apps/web/.env.production new file mode 100644 index 0000000..292a14c --- /dev/null +++ b/apps/web/.env.production @@ -0,0 +1 @@ +VITE_API_URL= From b09606f5f04faee8488ef1ee90049bcbbe08d65c Mon Sep 17 00:00:00 2001 From: "groombook-engineer[bot]" <269742240+groombook-engineer[bot]@users.noreply.github.com> Date: Sun, 29 Mar 2026 12:36:08 +0000 Subject: [PATCH 2/5] ci: add production promotion workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manual workflow_dispatch trigger to promote a tested image tag to production by creating an infra PR. No auto-merge — UAT sign-off required before prod deploy. Co-authored-by: groombook-ci[bot] Co-authored-by: Paperclip Co-authored-by: groombook-ceo[bot] <269735724+groombook-ceo[bot]@users.noreply.github.com> --- .github/workflows/promote-prod.yml | 63 ++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 .github/workflows/promote-prod.yml diff --git a/.github/workflows/promote-prod.yml b/.github/workflows/promote-prod.yml new file mode 100644 index 0000000..65cd94c --- /dev/null +++ b/.github/workflows/promote-prod.yml @@ -0,0 +1,63 @@ +name: Promote to Production + +on: + workflow_dispatch: + inputs: + tag: + description: "Image tag to promote (e.g. 2026.03.28-f1b85bf)" + required: true + type: string + +jobs: + promote: + name: Promote to Production + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Generate infra repo token + id: infra-token + uses: tibdex/github-app-token@v2 + with: + app_id: ${{ vars.GH_APP_ID }} + private_key: ${{ secrets.GH_APP_PRIVATE_KEY }} + + - name: Clone groombook/infra + run: | + git clone https://x-access-token:${{ steps.infra-token.outputs.token }}@github.com/groombook/infra.git /tmp/infra + + - name: Install yq + run: | + sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 + sudo chmod +x /usr/local/bin/yq + + - name: Update prod overlay image tags + env: + TAG: ${{ inputs.tag }} + run: | + cd /tmp/infra + PROD_KUST="apps/groombook/overlays/prod/kustomization.yaml" + yq -i '(.images[] | select(.name == "ghcr.io/groombook/api")).newTag = env(TAG)' "$PROD_KUST" + yq -i '(.images[] | select(.name == "ghcr.io/groombook/web")).newTag = env(TAG)' "$PROD_KUST" + yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$PROD_KUST" + yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$PROD_KUST" + git -C /tmp/infra diff --stat + + - name: Create PR on groombook/infra + env: + TAG: ${{ inputs.tag }} + GH_TOKEN: ${{ steps.infra-token.outputs.token }} + run: | + cd /tmp/infra + git config user.name "groombook-engineer[bot]" + git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com" + git checkout -b "release/promote-prod-${TAG}" + git add apps/groombook/overlays/prod/ + git commit -m "release: promote ${TAG} to production" + git push -u origin "release/promote-prod-${TAG}" + gh pr create \ + --repo groombook/infra \ + --base main \ + --head "release/promote-prod-${TAG}" \ + --title "release: promote ${TAG} to production" \ + --body "Promote image tag ${TAG} to production after UAT sign-off. cc @cpfarhood" \ No newline at end of file From 20920022a63b320126e64662c6b8b54d0a98ef44 Mon Sep 17 00:00:00 2001 From: "groombook-ceo[bot]" <269735724+groombook-ceo[bot]@users.noreply.github.com> Date: Sun, 29 Mar 2026 14:07:21 +0000 Subject: [PATCH 3/5] fix: increase deployment rollout timeout to 300s (GRO-147) (#148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squash merge. CTO + QA approved, all CI checks green. - Helm progressDeadlineSeconds: 120s → 300s (api + web) - CI kubectl rollout timeout: 120s → 300s Fixes groombook-dev CI deploy step timing out while pods complete successfully. cc @cpfarhood --- .github/workflows/ci.yml | 4 ++-- charts/groombook/templates/api-deployment.yaml | 1 + charts/groombook/templates/web-deployment.yaml | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27aea8f..ee2f56c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -246,8 +246,8 @@ jobs: kubectl set image deployment/web web=ghcr.io/groombook/web:$TAG -n groombook-dev # Wait for rollout - kubectl rollout status deployment/api -n groombook-dev --timeout=120s - kubectl rollout status deployment/web -n groombook-dev --timeout=120s + kubectl rollout status deployment/api -n groombook-dev --timeout=300s + kubectl rollout status deployment/web -n groombook-dev --timeout=300s echo "Deployment complete." diff --git a/charts/groombook/templates/api-deployment.yaml b/charts/groombook/templates/api-deployment.yaml index 8e118d4..aaee7b0 100644 --- a/charts/groombook/templates/api-deployment.yaml +++ b/charts/groombook/templates/api-deployment.yaml @@ -7,6 +7,7 @@ metadata: app.kubernetes.io/component: api spec: replicas: {{ .Values.api.replicas }} + progressDeadlineSeconds: 300 selector: matchLabels: {{- include "groombook.selectorLabels" . | nindent 6 }} diff --git a/charts/groombook/templates/web-deployment.yaml b/charts/groombook/templates/web-deployment.yaml index 9652811..f757dcc 100644 --- a/charts/groombook/templates/web-deployment.yaml +++ b/charts/groombook/templates/web-deployment.yaml @@ -7,6 +7,7 @@ metadata: app.kubernetes.io/component: web spec: replicas: {{ .Values.web.replicas }} + progressDeadlineSeconds: 300 selector: matchLabels: {{- include "groombook.selectorLabels" . | nindent 6 }} From 4dabb25ee17cd2e6f6c7e450973e149d34fc3403 Mon Sep 17 00:00:00 2001 From: "groombook-engineer[bot]" <269742240+groombook-engineer[bot]@users.noreply.github.com> Date: Sun, 29 Mar 2026 15:14:44 +0000 Subject: [PATCH 4/5] fix(portal/book): wire Rebook Now button + date format validation (GRO-265, GRO-266) * fix(portal): wire Rebook Now button to navigate to booking wizard (GRO-265) The "Rebook Now" button on the Report Card detail view had no click handler. Now navigates to /admin/book with pet info pre-filled via URL params (petName, serviceName). Button text changed from "Book Now" to "Rebook Now" per the bug report. Co-Authored-By: Paperclip * fix(book): pre-fill form from URL params to ensure React state is set Add useSearchParams to read URL parameters (e.g., ?clientName=Jane) and sync them to the BookingBody state on mount via useEffect. This ensures validation checks React state, not empty initial state. Fixes GRO-255 Co-Authored-By: Paperclip * fix(book): add inline validation for date input format (GRO-266) Date picker now shows a clear error when the value doesn't match YYYY-MM-DD, instead of silently failing with a browser console warning. Co-Authored-By: Paperclip * fix(portal): wire Rebook Now button + clean .js artifacts (GRO-265) Cherry-picked from contaminated PR #160: - ReportCards.tsx: Rebook Now button navigates to /admin/book with pet info - Book.tsx: pre-fill form from URL params (GRO-255) - Book.tsx: inline date validation (GRO-266) Also removes compiled .js artifacts (Book.js, ReportCards.js) that were incorrectly committed. Co-Authored-By: Paperclip --------- Co-authored-by: groombook-ci[bot] Co-authored-by: Paperclip --- apps/web/src/pages/Book.tsx | 39 +++++++++++++++++++- apps/web/src/portal/sections/ReportCards.tsx | 13 ++++++- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/apps/web/src/pages/Book.tsx b/apps/web/src/pages/Book.tsx index 0e0710d..dc58c9b 100644 --- a/apps/web/src/pages/Book.tsx +++ b/apps/web/src/pages/Book.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { useSearchParams } from "react-router-dom"; import type { Service } from "@groombook/types"; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -107,6 +108,7 @@ export function BookPage() { // Step 2 — date & time const [date, setDate] = useState(todayIso()); + const [dateError, setDateError] = useState(null); const [slots, setSlots] = useState([]); const [slotsLoading, setSlotsLoading] = useState(false); const [selectedSlot, setSelectedSlot] = useState(null); @@ -125,6 +127,28 @@ export function BookPage() { }); const [formError, setFormError] = useState(null); + // Pre-fill form from URL params (e.g., ?clientName=Jane&clientEmail=jane@example.com) + const [searchParams] = useSearchParams(); + useEffect(() => { + const clientName = searchParams.get("clientName"); + const clientEmail = searchParams.get("clientEmail"); + const clientPhone = searchParams.get("clientPhone"); + const petName = searchParams.get("petName"); + const petSpecies = searchParams.get("petSpecies"); + const petBreed = searchParams.get("petBreed"); + if (clientName || clientEmail || clientPhone || petName || petSpecies || petBreed) { + setForm((f) => ({ + ...f, + ...(clientName && { clientName }), + ...(clientEmail && { clientEmail }), + ...(clientPhone && { clientPhone }), + ...(petName && { petName }), + ...(petSpecies && { petSpecies }), + ...(petBreed && { petBreed }), + })); + } + }, [searchParams]); + // Step 4 — result const [submitting, setSubmitting] = useState(false); const [result, setResult] = useState(null); @@ -328,8 +352,21 @@ export function BookPage() { value={date} min={todayIso()} style={{ ...input, width: "auto" }} - onChange={(e) => setDate(e.target.value)} + onChange={(e) => { + const val = e.target.value; + // HTML5 date input enforces yyyy-MM-dd; empty value means invalid format + if (!val) { + setDateError("Please enter a valid date (YYYY-MM-DD)."); + setDate(""); + } else { + setDateError(null); + setDate(val); + } + }} /> + {dateError && ( +

{dateError}

+ )}
diff --git a/apps/web/src/portal/sections/ReportCards.tsx b/apps/web/src/portal/sections/ReportCards.tsx index f1136a3..a8d471b 100644 --- a/apps/web/src/portal/sections/ReportCards.tsx +++ b/apps/web/src/portal/sections/ReportCards.tsx @@ -241,8 +241,17 @@ function ReportCardDetail({ card, onBack }: { card: Appointment; onBack: () => v

Book your next visit

Schedule your next grooming appointment

- From 6cd2ea6ca9853ee98d57d09c386572b96d877510 Mon Sep 17 00:00:00 2001 From: "groombook-engineer[bot]" <269742240+groombook-engineer[bot]@users.noreply.github.com> Date: Sun, 29 Mar 2026 20:24:56 +0000 Subject: [PATCH 5/5] fix(portal): wire Pay Now button with payment modal (GRO-261) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes GRO-261 — Pay Now button on Billing page now opens a payment modal with invoice selection and simulated payment flow. Co-Authored-By: Paperclip --- .../src/portal/sections/BillingPayments.tsx | 426 ++++++++++++++---- 1 file changed, 342 insertions(+), 84 deletions(-) diff --git a/apps/web/src/portal/sections/BillingPayments.tsx b/apps/web/src/portal/sections/BillingPayments.tsx index 5ae9f81..bc110e3 100644 --- a/apps/web/src/portal/sections/BillingPayments.tsx +++ b/apps/web/src/portal/sections/BillingPayments.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from "react"; +import { CreditCard, DollarSign, Package, Zap } from "lucide-react"; interface Invoice { id: string; @@ -31,6 +32,9 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) { const [packages, setPackages] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [tab, setTab] = useState<"invoices" | "payment" | "packages">("invoices"); + const [autopay, setAutopay] = useState(false); + const [showPaymentModal, setShowPaymentModal] = useState(false); useEffect(() => { async function fetchData() { @@ -71,6 +75,9 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) { }).format(cents / 100); }; + const pending = invoices.filter((i) => i.status === "pending"); + const totalPending = pending.reduce((sum, i) => sum + i.totalCents, 0); + if (loading) { return (
@@ -92,98 +99,349 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) { } return ( -
-

Billing & Payments

- - {/* Payment Methods */} -
-

Payment Methods

- {paymentMethods.length === 0 ? ( -

No payment methods on file

- ) : ( -
- {paymentMethods.map((method) => ( -
-
-
- {method.brand.toUpperCase()} -
- **** {method.last4} - - {method.expiryMonth}/{method.expiryYear} - -
- {!readOnly && ( - - )} -
- ))} +
+ {/* Outstanding Balance Banner */} + {totalPending > 0 && ( +
+
+

Outstanding Balance

+

{formatCents(totalPending)}

+

+ {pending.length} unpaid invoice{pending.length > 1 ? "s" : ""} +

- )} -
+ {!readOnly && ( + + )} +
+ )} - {/* Packages */} -
-

Packages

- {packages.length === 0 ? ( -

No packages purchased

- ) : ( -
- {packages.map((pkg, index) => ( -
- {pkg.name} - {pkg.remaining} remaining -
- ))} -
- )} -
+ {/* Tabs */} +
+ {([ + { id: "invoices" as const, label: "Invoices", icon: DollarSign }, + { id: "payment" as const, label: "Payment Methods", icon: CreditCard }, + { id: "packages" as const, label: "Packages", icon: Package }, + ]).map(({ id, label, icon: Icon }) => ( + + ))} +
{/* Invoices */} -
-

Invoice History

- {invoices.length === 0 ? ( -

No invoices yet

- ) : ( -
- {invoices.map((invoice) => ( -
-
- - {invoice.description || `Invoice ${invoice.id.slice(0, 8)}`} - - {invoice.date} + {tab === "invoices" && ( +
+
+ + + + + + + + + + + + {invoices.map((inv) => ( + + + + + + + + ))} + +
DateDescriptionAmountStatus
+ {new Date(inv.date).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} + + {inv.description || `Invoice ${inv.id.slice(0, 8)}`} + + {formatCents(inv.totalCents)} + + + {inv.status.charAt(0).toUpperCase() + inv.status.slice(1)} + + + +
+
+
+ )} + + {/* Payment Methods */} + {tab === "payment" && ( +
+ {paymentMethods.length === 0 ? ( +

No payment methods on file

+ ) : ( +
+ {paymentMethods.map((method) => ( +
+
+
+ {method.brand.toUpperCase()} +
+ **** {method.last4} + + {method.expiryMonth}/{method.expiryYear} + +
+ {!readOnly && ( + + )}
-
- - {formatCents(invoice.totalCents)} - - - {invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)} - + ))} +
+ )} + + {/* Autopay */} +
+
+
+
+ +
+
+

Autopay

+

+ Automatically charge after each appointment +

- ))} + {!readOnly ? ( + + ) : ( + + {autopay ? "Enabled" : "Disabled"} + + )} +
- )} -
+
+ )} + + {/* Packages */} + {tab === "packages" && ( +
+ {packages.length === 0 ? ( +

No packages purchased

+ ) : ( + packages.map((pkg, index) => ( +
+
+ {pkg.name} + {pkg.remaining} remaining +
+
+ )) + )} +
+ )} + + {/* Payment Modal */} + {showPaymentModal && !readOnly && ( + setShowPaymentModal(false)} + /> + )} + + ); +} + +function PaymentModal({ + pending, + totalPending: _totalPending, + onClose, +}: { + pending: Invoice[]; + totalPending: number; + onClose: () => void; +}) { + const [selectedInvoices, setSelectedInvoices] = useState>( + new Set(pending.map((i) => i.id)) + ); + const [isProcessing, setIsProcessing] = useState(false); + const [isComplete, setIsComplete] = useState(false); + + const formatCents = (cents: number) => + new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(cents / 100); + + const toggleInvoice = (id: string) => { + const next = new Set(selectedInvoices); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + setSelectedInvoices(next); + }; + + const handlePay = async () => { + setIsProcessing(true); + await new Promise((resolve) => setTimeout(resolve, 1500)); + setIsProcessing(false); + setIsComplete(true); + }; + + const selectedTotal = pending + .filter((i) => selectedInvoices.has(i.id)) + .reduce((sum, i) => sum + i.totalCents, 0); + + if (isComplete) { + return ( +
+
+
+ + + +
+

Payment Successful

+

+ Your payment of {formatCents(selectedTotal)} has been processed. A receipt has been sent to your email. +

+ +
+
+ ); + } + + return ( +
+
+
+

Pay Outstanding Balance

+ +
+ +

Select invoices to pay:

+ +
+ {pending.map((inv) => ( + + ))} +
+ +
+
+ Total + + {formatCents(selectedTotal)} + +
+
+ +
+ + +
+
); }