Embed mini widget + prominent back button

Embed mode (?embed=1 or iframe detection):
- Shows sleek mini card (Make a Pledge) instead of full step 1
- 160px at rest, expands to 700px when user starts the flow
- postMessage resize signal for parent iframe auto-height
- Powered-by footer

Back button:
- Moved from hidden bottom bar to fixed top navigation bar
- ChevronLeft + "Back" text, always visible during backable steps
- Org name centered in header, step label on right
- Progress bar integrated into top bar

Embed code updated:
- iframe starts at height=160 (mini widget height)
- Includes resize listener script for auto-expansion
This commit is contained in:
2026-03-05 18:06:08 +08:00
parent 5c615ad35e
commit b6384da417
5 changed files with 183 additions and 41 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -126,7 +126,7 @@ export default function CollectPage() {
setCopiedCode(code); setTimeout(() => setCopiedCode(null), 2000) setCopiedCode(code); setTimeout(() => setCopiedCode(null), 2000)
} }
const copyEmbed = async (code: string) => { const copyEmbed = async (code: string) => {
const snippet = `<iframe src="${baseUrl}/p/${code}" width="100%" height="700" style="border:none;max-width:480px;" title="Make a Pledge"></iframe>` const snippet = `<iframe src="${baseUrl}/p/${code}?embed=1" width="100%" height="160" style="border:none;max-width:480px;" title="Make a Pledge"></iframe>\n<script>window.addEventListener("message",e=>{if(e.data?.type==="pnpl-resize")document.querySelector('iframe[src*="${code}"]').style.height=e.data.height+"px"});<\/script>`
await navigator.clipboard.writeText(snippet) await navigator.clipboard.writeText(snippet)
setCopiedEmbed(code); setTimeout(() => setCopiedEmbed(null), 2000) setCopiedEmbed(code); setTimeout(() => setCopiedEmbed(null), 2000)
} }
@@ -773,7 +773,7 @@ export default function CollectPage() {
<div className="space-y-3"> <div className="space-y-3">
<p className="text-xs text-gray-600">Add the pledge form to your website. Copy this code and paste it into your HTML:</p> <p className="text-xs text-gray-600">Add the pledge form to your website. Copy this code and paste it into your HTML:</p>
<div className="bg-[#111827] p-4 overflow-x-auto"> <div className="bg-[#111827] p-4 overflow-x-auto">
<code className="text-[11px] text-[#4ADE80] font-mono whitespace-pre">{`<iframe\n src="${url}"\n width="100%"\n height="700"\n style="border:none;max-width:480px;"\n title="Pledge: ${src.label}"\n></iframe>`}</code> <code className="text-[11px] text-[#4ADE80] font-mono whitespace-pre">{`<iframe\n src="${url}?embed=1"\n width="100%"\n height="160"\n style="border:none;max-width:480px;"\n title="Pledge: ${src.label}"\n></iframe>`}</code>
</div> </div>
<button onClick={() => copyEmbed(src.code)} className={`w-full py-2.5 text-xs font-bold flex items-center justify-center gap-1.5 ${ <button onClick={() => copyEmbed(src.code)} className={`w-full py-2.5 text-xs font-bold flex items-center justify-center gap-1.5 ${
isEmbedCopied ? "bg-[#16A34A] text-white" : "bg-[#111827] text-white hover:bg-gray-800" isEmbedCopied ? "bg-[#16A34A] text-white" : "bg-[#111827] text-white hover:bg-gray-800"

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import { useParams } from "next/navigation" import { useParams, useSearchParams } from "next/navigation"
import { AmountStep } from "./steps/amount-step" import { AmountStep } from "./steps/amount-step"
import { ScheduleStep } from "./steps/schedule-step" import { ScheduleStep } from "./steps/schedule-step"
import { PaymentStep } from "./steps/payment-step" import { PaymentStep } from "./steps/payment-step"
@@ -11,6 +11,7 @@ import { BankInstructionsStep } from "./steps/bank-instructions-step"
import { ExternalRedirectStep } from "./steps/external-redirect-step" import { ExternalRedirectStep } from "./steps/external-redirect-step"
import { CardPaymentStep } from "./steps/card-payment-step" import { CardPaymentStep } from "./steps/card-payment-step"
import { DirectDebitStep } from "./steps/direct-debit-step" import { DirectDebitStep } from "./steps/direct-debit-step"
import { Heart, ChevronLeft, ArrowRight } from "lucide-react"
export type Rail = "bank" | "gocardless" | "card" export type Rail = "bank" | "gocardless" | "card"
@@ -56,20 +57,32 @@ interface EventInfo {
/* /*
Flow: Flow:
-1 = Mini widget (embed only — sleek card that starts the flow)
0 = Amount 0 = Amount
1 = Schedule (When to pay?) ← NEW 1 = Schedule (When to pay?)
2 = Payment method (if "now") or Identity (if deferred) 2 = Payment method (if "now") or Identity (if deferred)
3 = Identity (for bank transfer "now") 3 = Identity (for bank transfer "now")
4 = Bank instructions (now) 4 = Bank instructions (now)
5 = Confirmation (generic — card, DD, or deferred pledge) 5 = Confirmation (generic — card, DD, or deferred pledge)
6 = Card payment 6 = Card payment
7 = External redirect
8 = Direct Debit 8 = Direct Debit
*/ */
export default function PledgePage() { export default function PledgePage() {
const params = useParams() const params = useParams()
const searchParams = useSearchParams()
const token = params.token as string const token = params.token as string
const [step, setStep] = useState(0)
// Detect embed: ?embed=1 query param OR inside an iframe
const [isEmbed, setIsEmbed] = useState(false)
useEffect(() => {
const embedParam = searchParams.get("embed") === "1"
const inIframe = typeof window !== "undefined" && window.self !== window.top
setIsEmbed(embedParam || inIframe)
}, [searchParams])
const [step, setStep] = useState<number>(0)
const [eventInfo, setEventInfo] = useState<EventInfo | null>(null) const [eventInfo, setEventInfo] = useState<EventInfo | null>(null)
const [pledgeData, setPledgeData] = useState<PledgeData>({ const [pledgeData, setPledgeData] = useState<PledgeData>({
amountPence: 0, amountPence: 0,
@@ -110,6 +123,23 @@ export default function PledgePage() {
}).catch(() => {}) }).catch(() => {})
}, [token]) }, [token])
// Start at mini widget step for embeds once loaded
useEffect(() => {
if (!loading && isEmbed && step === 0) {
setStep(-1)
}
}, [loading, isEmbed, step])
// Notify parent frame of height changes when embedded
useEffect(() => {
if (!isEmbed) return
const sendHeight = () => {
const height = step === -1 ? 160 : 700
window.parent.postMessage({ type: "pnpl-resize", height }, "*")
}
sendHeight()
}, [isEmbed, step])
const isExternal = eventInfo?.paymentMode === "external" && eventInfo?.externalUrl const isExternal = eventInfo?.paymentMode === "external" && eventInfo?.externalUrl
// Step 0: Amount selected // Step 0: Amount selected
@@ -232,6 +262,7 @@ export default function PledgePage() {
: undefined : undefined
const steps: Record<number, React.ReactNode> = { const steps: Record<number, React.ReactNode> = {
[-1]: <MiniWidget eventInfo={eventInfo} onStart={() => setStep(0)} />,
0: <AmountStep onSelect={handleAmountSelected} eventName={eventInfo?.name || ""} eventId={eventInfo?.id} goalAmount={eventInfo?.goalAmount} />, 0: <AmountStep onSelect={handleAmountSelected} eventName={eventInfo?.name || ""} eventId={eventInfo?.id} goalAmount={eventInfo?.goalAmount} />,
1: <ScheduleStep amount={pledgeData.amountPence} onSelect={handleScheduleSelected} />, 1: <ScheduleStep amount={pledgeData.amountPence} onSelect={handleScheduleSelected} />,
2: <PaymentStep onSelect={handleRailSelected} amount={pledgeData.amountPence} hasStripe={eventInfo?.hasStripe ?? false} />, 2: <PaymentStep onSelect={handleRailSelected} amount={pledgeData.amountPence} hasStripe={eventInfo?.hasStripe ?? false} />,
@@ -260,47 +291,158 @@ export default function PledgePage() {
const backableSteps = new Set([1, 2, 3, 6, 8]) const backableSteps = new Set([1, 2, 3, 6, 8])
const getBackStep = (s: number): number => { const getBackStep = (s: number): number => {
if (s === 0 && isEmbed) return -1 // amount → mini widget (embed only)
if (s === 6 || s === 8) return 2 // card/DD → payment method if (s === 6 || s === 8) return 2 // card/DD → payment method
if (s === 3 && pledgeData.scheduleMode !== "now") return 1 // deferred identity → schedule if (s === 3 && pledgeData.scheduleMode !== "now") return 1 // deferred identity → schedule
if (s === 3) return 2 // bank identity → payment method if (s === 3) return 2 // bank identity → payment method
return s - 1 return s - 1
} }
const progressMap: Record<number, number> = { 0: 8, 1: 25, 2: 40, 3: 60, 4: 100, 5: 100, 6: 60, 7: 100, 8: 60 } // Can go back from step 0 only in embed mode
const progressPercent = progressMap[step] || 10 const canGoBack = backableSteps.has(step) || (step === 0 && isEmbed)
const isFinished = step === 4 || step === 5 || step === 7
const stepLabels: Record<number, string> = {
[-1]: "", 0: "Amount", 1: "Schedule", 2: "Payment method",
3: "Your details", 4: "Complete", 5: "Complete", 6: "Card payment",
7: "Redirecting", 8: "Direct Debit",
}
const progressMap: Record<number, number> = { [-1]: 0, 0: 8, 1: 25, 2: 40, 3: 60, 4: 100, 5: 100, 6: 60, 7: 100, 8: 60 }
const progressPercent = progressMap[step] ?? 10
// Mini widget mode — compact layout, no chrome
if (step === -1) {
return (
<div className={isEmbed ? "bg-paper" : "min-h-screen bg-paper"}>
<div className="px-4 py-4">{steps[-1]}</div>
</div>
)
}
return ( return (
<div className="min-h-screen bg-paper"> <div className="min-h-screen bg-paper">
{/* ── Top bar: progress + back + step label ── */}
<div className="fixed top-0 left-0 right-0 z-50 bg-white/95 backdrop-blur-sm border-b border-gray-100">
{/* Progress bar */} {/* Progress bar */}
<div className="fixed top-0 left-0 right-0 h-1 bg-gray-100 z-50"> <div className="h-1 bg-gray-100">
<div <div
className="h-full bg-promise-blue transition-all duration-700 ease-out" className="h-full bg-promise-blue transition-all duration-700 ease-out"
style={{ width: `${progressPercent}%` }} style={{ width: `${progressPercent}%` }}
/> />
</div> </div>
{/* Header */} {/* Navigation row */}
<div className="pt-6 pb-1 px-4 text-center"> <div className="flex items-center h-12 px-3">
<p className="text-xs font-medium text-muted-foreground">{eventInfo?.organizationName}</p> {canGoBack ? (
{eventInfo?.qrSourceLabel && (
<p className="text-[10px] text-muted-foreground/60">{eventInfo.qrSourceLabel}</p>
)}
</div>
{/* Content */}
<div className="px-4 pb-20">{steps[step]}</div>
{/* Back button */}
{backableSteps.has(step) && (
<div className="fixed bottom-0 left-0 right-0 pb-6 pt-4 px-4 bg-white">
<button <button
onClick={() => setStep(getBackStep(step))} onClick={() => setStep(getBackStep(step))}
className="text-sm text-muted-foreground hover:text-foreground transition-colors tap-target flex items-center gap-1" className="flex items-center gap-1 text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors -ml-1 px-2 py-1.5 rounded-lg hover:bg-gray-100 active:bg-gray-200"
> >
Back <ChevronLeft className="h-4 w-4" />
<span>Back</span>
</button> </button>
) : (
<div className="w-16" /> /* Spacer */
)}
<div className="flex-1 text-center">
<p className="text-xs font-medium text-muted-foreground">
{eventInfo?.organizationName}
</p>
</div>
{!isFinished && step >= 0 ? (
<span className="text-[10px] font-medium text-muted-foreground/60 w-16 text-right">
{stepLabels[step]}
</span>
) : (
<div className="w-16" />
)}
</div>
</div>
{/* QR source label */}
{eventInfo?.qrSourceLabel && (
<div className="pt-14 pb-0 px-4 text-center">
<p className="text-[10px] text-muted-foreground/60">{eventInfo.qrSourceLabel}</p>
</div> </div>
)} )}
{/* Content — padded for fixed header */}
<div className={`px-4 pb-8 ${eventInfo?.qrSourceLabel ? 'pt-2' : 'pt-16'}`}>
{steps[step]}
</div>
</div>
)
}
/* ── Mini Widget ─────────────────────────────────────────────────
Compact card shown in embedded mode. Designed to be:
- 80-120px tall at rest
- Branded but not loud
- Single CTA button that launches the full flow
────────────────────────────────────────────────────────────── */
function MiniWidget({
eventInfo,
onStart,
}: {
eventInfo: EventInfo | null
onStart: () => void
}) {
const [hover, setHover] = useState(false)
return (
<div className="max-w-md mx-auto animate-fade-up">
<button
onClick={onStart}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
className="w-full text-left rounded-2xl border-2 border-gray-200 bg-white p-5 transition-all duration-300 hover:border-trust-blue hover:shadow-xl group cursor-pointer"
>
<div className="flex items-center gap-4">
{/* Icon */}
<div className={`shrink-0 w-12 h-12 rounded-xl flex items-center justify-center transition-all duration-300 ${
hover ? "bg-trust-blue scale-110" : "bg-midnight"
}`}>
<Heart className={`h-6 w-6 text-white transition-transform duration-300 ${hover ? "scale-110" : ""}`} />
</div>
{/* Text */}
<div className="flex-1 min-w-0">
<h2 className="text-lg font-black text-gray-900 tracking-tight leading-tight">
Make a Pledge
</h2>
<p className="text-sm text-muted-foreground mt-0.5 truncate">
{eventInfo?.name || "Support this cause"}
</p>
</div>
{/* Arrow */}
<div className={`shrink-0 w-10 h-10 rounded-full flex items-center justify-center transition-all duration-300 ${
hover ? "bg-trust-blue text-white translate-x-0.5" : "bg-gray-100 text-gray-400"
}`}>
<ArrowRight className="h-5 w-5" />
</div>
</div>
{/* Sub-line */}
<div className="flex items-center gap-3 mt-3 pt-3 border-t border-gray-100">
<span className="text-xs text-muted-foreground">
💳 Card, bank transfer, or Direct Debit
</span>
<span className="text-xs text-muted-foreground"></span>
<span className="text-xs text-success-green font-medium">
🎁 +25% Gift Aid
</span>
</div>
</button>
{/* Powered by */}
<p className="text-center text-[10px] text-muted-foreground/40 mt-2">
Powered by <span className="font-medium">Pledge Now, Pay Later</span>
</p>
</div> </div>
) )
} }