diff --git a/App.tsx b/App.tsx index a8d114f..4afee1c 100644 --- a/App.tsx +++ b/App.tsx @@ -921,6 +921,15 @@ const App: React.FC = ({ formConfig: providedFormConfig, defaultCondit screen.type !== 'interstitial' && screen.type !== 'content'; + // Handler to navigate to email capture screen for sign-in + const handleSignInClick = () => { + // Set a flag in sessionStorage to indicate user clicked sign-in + if (typeof window !== 'undefined') { + sessionStorage.setItem('zappy_sign_in_clicked', 'true'); + } + goToScreen('capture.email'); + }; + const commonProps = { screen, answers, @@ -931,6 +940,7 @@ const App: React.FC = ({ formConfig: providedFormConfig, defaultCondit onBack: screen.id === 'complete.assessment_review' ? undefined : goToPrev, defaultCondition: resolvedCondition, showLoginLink, + onSignInClick: handleSignInClick, // Add sign-in handler }; if (screen.id === 'treatment.glp1_history') { diff --git a/components/common/NavigationButtons.tsx b/components/common/NavigationButtons.tsx index 25a549a..b956a5e 100644 --- a/components/common/NavigationButtons.tsx +++ b/components/common/NavigationButtons.tsx @@ -1,5 +1,6 @@ import { ArrowRight, Check } from 'lucide-react'; import { motion } from 'framer-motion'; +import { useState, useEffect } from 'react'; interface NavigationButtonsProps { showBack?: boolean; @@ -10,6 +11,7 @@ interface NavigationButtonsProps { nextLabel?: string; nextButtonType?: 'button' | 'submit'; layout?: 'spread' | 'grouped'; // New prop for layout variant + onSignInClick?: () => void; // Optional handler for sign-in link } export default function NavigationButtons({ @@ -21,38 +23,78 @@ export default function NavigationButtons({ nextLabel = 'Continue', nextButtonType = 'button', layout = 'spread', + onSignInClick, }: NavigationButtonsProps) { + const [showSignInLink, setShowSignInLink] = useState(false); + + // Check if zappy_auth_token exists in localStorage + useEffect(() => { + const checkAuthToken = () => { + if (typeof window !== 'undefined') { + const authToken = sessionStorage.getItem('client_record_id'); + setShowSignInLink(!authToken); + } + }; + + checkAuthToken(); + // Also check on storage events (in case token is added/removed in another tab) + if (typeof window !== 'undefined') { + window.addEventListener('storage', checkAuthToken); + return () => window.removeEventListener('storage', checkAuthToken); + } + }, []); + // Always center the continue button, no back button at bottom // Unified button styling matching DemographicsScreen, EmailCaptureScreen, etc. return ( - - + - {isNextLoading && ( - - - - - )} - {nextLabel} - {!isNextLoading && } - - + + {isNextLoading && ( + + + + + )} + {nextLabel} + {!isNextLoading && } + + + + {/* Sign-in link - Show if no auth token and handler is provided */} + {showSignInLink && onSignInClick && ( + + + + )} + ); } diff --git a/components/screens/AccountCreationScreen.tsx b/components/screens/AccountCreationScreen.tsx index a4d62e2..9590cbe 100644 --- a/components/screens/AccountCreationScreen.tsx +++ b/components/screens/AccountCreationScreen.tsx @@ -144,14 +144,12 @@ function MockPaymentForm({ onComplete, onBack, updateAnswer, - onProcessingChange, }: { selectedPlan: any; answers: Record; onComplete: (accountData: any) => void; onBack: () => void; updateAnswer: (id: string, value: any) => void; - onProcessingChange?: (isProcessing: boolean) => void; }) { const getString = (value: unknown, fallback: string = ""): string => typeof value === "string" ? value : fallback; @@ -323,12 +321,9 @@ function MockPaymentForm({ const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!validateForm()) { - return; - } + if (!validateForm()) return; setIsProcessing(true); - onProcessingChange?.(true); try { // If we have card details and a customer ID, add the card to Stripe @@ -427,7 +422,6 @@ function MockPaymentForm({ "Failed to process payment method. Please try again.", }); setIsProcessing(false); - onProcessingChange?.(false); return; } } else { @@ -454,7 +448,6 @@ function MockPaymentForm({ setErrors({ general: "An error occurred. Please try again." }); } finally { setIsProcessing(false); - onProcessingChange?.(false); } }; @@ -554,8 +547,50 @@ function MockPaymentForm({ const planName = selectedPlan?.name || answers["selected_plan_name"] || "Selected Plan"; const planPrice = - selectedPlan?.per_month_price || answers["selected_plan_price"] || 299; - const invoiceAmount = selectedPlan?.invoice_amount || answers["selected_plan_invoice_amount"] || 299; + selectedPlan?.per_month_price || answers["selected_plan_price"]; + const invoiceAmount = + selectedPlan?.invoice_amount || + answers["selected_plan_invoice_amount"] || + 299; + + // Extract plan details for multi-month display + const planType = + selectedPlan?.plan || answers["selected_plan_type"] || "month"; // 'month', '3-month', '12-month' + const billingFrequency = + selectedPlan?.billingFrequency || "Billed every 4 weeks"; + const savings = + selectedPlan?.savings || + appliedDiscount?.amount || + answers["discount_amount"] || + null; + const savingsPercent = + selectedPlan?.savingsPercent || + (savings && planPrice > 0 ? Math.round((savings / planPrice) * 100) : 0); + + // Calculate total amounts based on plan type + const getOrderTotals = () => { + if (planType === "3 month") { + return { + totalDue: planPrice * 3, + billingCycle: "3 months", + deliveries: 3, + }; + } else if (planType === "12 month") { + return { + totalDue: planPrice * 12, + billingCycle: "annually", + deliveries: 12, + }; + } + // Default to monthly + return { + totalDue: planPrice, + billingCycle: "monthly", + deliveries: 1, + }; + }; + + const orderTotals = getOrderTotals(); // Determine CTA text and title based on user state const ctaText = isExistingCustomer @@ -580,33 +615,105 @@ function MockPaymentForm({ transition={{ delay: 0.1 }} className="bg-white rounded-2xl p-6 shadow-sm border border-[#E8E8E8]" > - {/* Plan Summary */} -
-
-
+ {/* Plan Header */} +
+
+
+

{planName}

-
-

{planName}

-

{selectedMedication}

-
+

{selectedMedication}

+ {savings && savings > 0 && ( +
+ + + Save ${savings} ({savingsPercent}% off) + +
+ )}
-
- ${planPrice} +
+ ${planPrice} + /mo +
+
+
+ + {/* Benefits */} +
+
+
+ +
+

No insurance required

+
+
+
+ +
+

+ Ongoing provider support & dosage adjustments +

+
+
+
+
-
/month
+

+ Cancel anytime, no long-term commitment +

- {/* Payment Info */} -
-
+ {/* Pricing Breakdown */} +
+ {planType !== "month" ? ( + <> +
+ + {planName} ({planType}) + + + ${planPrice}/mo × {orderTotals.deliveries} + +
+
+ + Billed {orderTotals.billingCycle} + + ${orderTotals.totalDue} +
+ {savings && savings > 0 && ( +
+ Your savings + -${savings} +
+ )} + + ) : ( +
+ + Monthly subscription + + ${planPrice} +
+ )} +
+ Total + ${invoiceAmount} +
+
Due today - $0 + $0
+
+ + {/* Important Notice */} +

- You'll be charged ${invoiceAmount} once prescribed. You won't be charged if a provider determines that our program isn't right for you. + You'll be charged ${planPrice} once prescribed. You won't be charged + if a provider determines that our program isn't right for you.

@@ -631,90 +738,77 @@ function MockPaymentForm({
-
-
- { - setDiscountCode(event.target.value.toUpperCase()); - setDiscountError(""); - }} - /> - -
- {discountError && ( -
- -

{discountError}

-
- )} +
+ { + setDiscountCode(event.target.value.toUpperCase()); + setDiscountError(""); + }} + /> +
-
- - {appliedDiscount && ( -
-
-
-
- - - - - Discount applied! - -
-

- Code:{" "} - {appliedDiscount.code} -

-

- Discount:{" "} - {appliedDiscount.percentage > 0 - ? `${appliedDiscount.percentage}% off` - : `$${appliedDiscount.amount} off`} -

- {appliedDiscount.description && ( -

- {appliedDiscount.description} + {discountError && ( +

+ +

{discountError}

+
+ )} + {appliedDiscount && ( +
+
+
+
+ + + Discount applied! + +
+

+ Code:{" "} + {appliedDiscount.code} +

+

+ Discount:{" "} + {appliedDiscount.percentage > 0 + ? `${appliedDiscount.percentage}% off` + : `$${appliedDiscount.amount} off`}

- )} + {appliedDiscount.description && ( +

+ {appliedDiscount.description} +

+ )} +
+
-
-
- )} + )} +
@@ -1088,9 +1182,9 @@ function MockPaymentForm({ layout="grouped" /> - {/*

+

🔒 This is a demo form - no actual payment will be processed -

*/} +

); } @@ -1107,90 +1201,77 @@ export default function AccountCreationScreen({ }: ScreenProps & { key?: string }) { const selectedPlan = answers["selected_plan_details"] || {}; const selectedMedication = answers["selected_medication"] || "Medication"; - const [isProcessing, setIsProcessing] = useState(false); return ( - <> - {isProcessing ? ( -
-
-

Processing...

-
- ) : ( -
- {/* Full-page spinner overlay */} -
- +
+ + {/* Title */} +
+ - {/* Title */} -
- - You made it! 🎉 - - - - We just need your shipping address and ID to complete your - order. - -
+ You made it! 🎉 +
- {/* Form */} - { - // Save account data to answers - Object.entries(accountData).forEach(([key, value]) => { - updateAnswer(`account_${key}`, value); - if (key === "email" || key === "phone") { - updateAnswer(key, value); - } - if (key === "state") { - const stateCode = normalizeStateCode(value); - updateAnswer("state", stateCode); - updateAnswer("home_state", stateCode); - updateAnswer("shipping_state", stateCode); - } - if (key === "city") { - updateAnswer("city", value); - updateAnswer("shipping_city", value); - } - if (key === "address") { - updateAnswer("address_line1", value); - updateAnswer("shipping_address", value); - } - if (key === "address2") { - updateAnswer("address_line2", value); - updateAnswer("shipping_address2", value); - updateAnswer("unit", value); - } - if (key === "zipCode") { - updateAnswer("zip_code", value); - updateAnswer("shipping_zip", value); - } - }); - // Submit the form - onSubmit(accountData); - }} - onBack={onBack} - /> - + + We just need your shipping address and ID to complete your order. +
-
- )} - + + {/* Form */} + { + // Save account data to answers + Object.entries(accountData).forEach(([key, value]) => { + updateAnswer(`account_${key}`, value); + if (key === "email" || key === "phone") { + updateAnswer(key, value); + } + if (key === "state") { + const stateCode = normalizeStateCode(value); + updateAnswer("state", stateCode); + updateAnswer("home_state", stateCode); + updateAnswer("shipping_state", stateCode); + } + if (key === "city") { + updateAnswer("city", value); + updateAnswer("shipping_city", value); + } + if (key === "address") { + updateAnswer("address_line1", value); + updateAnswer("shipping_address", value); + } + if (key === "address2") { + updateAnswer("address_line2", value); + updateAnswer("shipping_address2", value); + updateAnswer("unit", value); + } + if (key === "zipCode") { + updateAnswer("zip_code", value); + updateAnswer("shipping_zip", value); + } + }); + // Submit the form + onSubmit(accountData); + }} + onBack={onBack} + /> +
+
+
); } diff --git a/components/screens/CompositeScreen.tsx b/components/screens/CompositeScreen.tsx index 6c6683e..6c8bba6 100644 --- a/components/screens/CompositeScreen.tsx +++ b/components/screens/CompositeScreen.tsx @@ -128,10 +128,11 @@ const getYearsAgoDate = (years: number): Date => { return date; }; -const CompositeScreen: React.FC }> = ({ screen, answers, updateAnswer, onSubmit, showBack, onBack, headerSize, calculations = {}, showLoginLink, apiPopulatedFields = new Set() }) => { +const CompositeScreen: React.FC }> = ({ screen, answers, updateAnswer, onSubmit, showBack, onBack, headerSize, calculations = {}, showLoginLink, onSignInClick, apiPopulatedFields = new Set() }) => { const { title, help_text, fields, footer_note, validation, post_screen_note } = screen; const [errors, setErrors] = useState>({}); const autoAdvanceTimeoutRef = React.useRef(null); + const pendingScrollToFieldRef = React.useRef(null); useEffect(() => { const initialState = answers['home_state']; @@ -148,6 +149,38 @@ const CompositeScreen: React.FC { + if (pendingScrollToFieldRef.current) { + const fieldId = pendingScrollToFieldRef.current; + + // Use requestAnimationFrame to ensure DOM has updated + requestAnimationFrame(() => { + const fieldElement = document.querySelector(`[data-field-id="${fieldId}"]`); + + if (fieldElement) { + const rect = fieldElement.getBoundingClientRect(); + const isVisible = rect.width > 0 && rect.height > 0; + + if (isVisible) { + // Add a small offset to account for fixed headers + const offset = -80; + const elementPosition = fieldElement.getBoundingClientRect().top + window.pageYOffset; + const offsetPosition = elementPosition + offset; + + window.scrollTo({ + top: offsetPosition, + behavior: 'smooth' + }); + + // Clear the pending scroll + pendingScrollToFieldRef.current = null; + } + } + }); + } + }, [answers]); // Re-run when answers change (fields become visible) + const allFields = useMemo(() => { const flattened: Field[] = []; const recurse = (items: FieldOrFieldGroup[]) => { @@ -647,15 +680,85 @@ const CompositeScreen: React.FC { - onSubmit(); - }, delay); + + // Find the next field that will be shown + // Check both progressive_display and conditional_display + const currentFieldIndex = allFields.findIndex(f => f.id === field.id); + const tempAnswers = { ...answers, [field.id]: val }; + + // First, try to find a field that directly depends on the current field + let nextField = allFields.find((f, index) => { + // Skip fields before the current one + if (index <= currentFieldIndex) return false; + + // Check if field has progressive_display pointing to current field + if (f.progressive_display?.show_after_field === field.id) { + return true; + } + + // Check if field has conditional_display that depends on current field + if (f.conditional_display?.show_if) { + const showIf = f.conditional_display.show_if; + // Check if the condition references the current field + if (showIf.includes(`${field.id} ==`) || showIf.includes(`${field.id}!=`)) { + // Check if field will be visible with the new answer + return shouldShowField(f, tempAnswers); + } + } + + return false; + }); + + // If no direct dependency found, find the next visible field + if (!nextField) { + nextField = allFields.find((f, index) => { + if (index <= currentFieldIndex) return false; + return shouldShowField(f, tempAnswers); + }); + } + + if (nextField) { + // Wait for visual confirmation (300-600ms), using 500ms + const delay = 500; + autoAdvanceTimeoutRef.current = setTimeout(() => { + // Set the field ID to scroll to - the useEffect will handle the actual scrolling + // when the field becomes visible after React re-renders + pendingScrollToFieldRef.current = nextField.id; + + // Try to scroll immediately in case the field is already visible + requestAnimationFrame(() => { + const nextFieldElement = document.querySelector(`[data-field-id="${nextField.id}"]`); + if (nextFieldElement) { + const rect = nextFieldElement.getBoundingClientRect(); + const isVisible = rect.width > 0 && rect.height > 0; + + if (isVisible) { + const offset = -80; + const elementPosition = nextFieldElement.getBoundingClientRect().top + window.pageYOffset; + const offsetPosition = elementPosition + offset; + + window.scrollTo({ + top: offsetPosition, + behavior: 'smooth' + }); + pendingScrollToFieldRef.current = null; + } + } + }); + }, delay); + } else { + // No next field, submit the screen to go to next step + // Wait for visual confirmation first + const delay = 500; + autoAdvanceTimeoutRef.current = setTimeout(() => { + onSubmit(); + }, delay); + } } }; return ( -
+
{field.label && (