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:
BIN
pledge-now-pay-later/screenshots/collect-wa-new.png
Normal file
BIN
pledge-now-pay-later/screenshots/collect-wa-new.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
BIN
pledge-now-pay-later/screenshots/home-wa-new.png
Normal file
BIN
pledge-now-pay-later/screenshots/home-wa-new.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
BIN
pledge-now-pay-later/screenshots/wa-banner-new.png
Normal file
BIN
pledge-now-pay-later/screenshots/wa-banner-new.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user