From 46feccd0fd984ec08390047070e756c3d767c0b9 Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Sun, 12 Jul 2026 12:22:53 +0100 Subject: [PATCH] fix(dashboard): restore paid course payment modal on landing page (#737) * fix(dashboard): restore paid course payment modal on landing page Wire the existing PaymentModal back into the theme-based course landing page. Paid course CTAs now open the name/email modal and redirect to the configured payment link instead of navigating to the blocked enroll route. Co-authored-by: Rotimi Best * fix(dashboard): reset payment modal on close without effect loop Co-authored-by: Rotimi Best * docs: add Svelte 5 rerender and effect loop guidance to AGENTS.md Co-authored-by: Rotimi Best * fix(dashboard): avoid double-applying discount on course landing pricing Co-authored-by: Rotimi Best --------- Co-authored-by: Cursor Agent Co-authored-by: Rotimi Best --- AGENTS.md | 67 +++++++++++++++++++ .../components/payment-modal.svelte | 21 ++++-- .../course-landing-page.svelte | 36 +++++++++- .../features/ui/course-landing-page/utils.ts | 18 ++++- .../custom/org-landing-page/bold/hero.svelte | 11 ++- .../org-landing-page/classic/hero.svelte | 11 +-- .../org-landing-page/corporate/hero.svelte | 10 +-- .../course-landing-page.helpers.ts | 3 +- .../org-landing-page/course-pricing.svelte | 8 ++- .../course-primary-action.svelte | 24 +++++++ .../org-landing-page/editorial/hero.svelte | 11 ++- .../org-landing-page/minimal/hero.svelte | 11 +-- .../custom/org-landing-page/saas/hero.svelte | 11 +-- .../org-landing-page/studio/hero.svelte | 10 +-- .../custom/org-landing-page/tech/hero.svelte | 11 ++- .../org-landing-page/terminal/hero.svelte | 10 ++- .../ui/src/custom/org-landing-page/types.ts | 12 +++- .../org-landing-page/vibrant/hero.svelte | 11 ++- 18 files changed, 207 insertions(+), 89 deletions(-) create mode 100644 packages/ui/src/custom/org-landing-page/course-primary-action.svelte diff --git a/AGENTS.md b/AGENTS.md index c61436f2..a3691949 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -320,6 +320,69 @@ In Svelte 5, the built-in `Set` and `Map` classes are **not** reactive. Use `Sve ``` +### Avoiding Svelte 5 rerender and `$effect` loops + +`$effect` re-runs when any reactive value it **reads** changes. Writing state inside an effect can retrigger it and cause infinite loops, redundant work, or flicker. Prefer explicit event handlers and bindings over effect-driven syncing. + +**Do not reset UI state in an effect tied to a steady condition** (e.g. “whenever `open` is false”). While the condition stays true, the effect keeps running and rewriting state: + +```svelte +// ❌ don't — resets on every run while closed; can loop with bound children +$effect(() => { + if (open) return; + + step = STEPS.STEP_1; + fields = { fullname: '', email: '' }; + errors = { fullname: '', email: '' }; +}); +``` + +**Reset on transitions** — when a dialog closes, a route changes, or the user submits — not on every render where a flag is false: + +```svelte +// ✅ do — reset only when the dialog actually closes +function resetForm() { + step = STEPS.STEP_1; + fields.fullname = ''; + fields.email = ''; + errors.fullname = ''; + errors.email = ''; +} + +function handleOpenChange(isOpen: boolean) { + open = isOpen; + + if (!isOpen) { + resetForm(); + } +} +``` + +```svelte + +``` + +**Prefer `bind:` over `$effect` for keeping two sources in sync.** If a control can bind directly to a store or `$state` field, use that instead of reading one value in an effect and writing another (see **Svelte store binding** above). + +**When resetting bound form state, mutate properties in place** instead of replacing the whole object. Reassigning `fields = { ... }` breaks `bind:value={fields.fullname}` and can trigger extra updates downstream: + +```svelte +// ❌ don't +fields = { fullname: '', email: '' }; + +// ✅ do +fields.fullname = ''; +fields.email = ''; +``` + +**Other common pitfalls:** + +- **Effect writes what it reads** — e.g. `$effect(() => { count = count + 1 })` loops forever. +- **Effect mirrors props into local state** — use `$bindable`, `bind:`, or derive a value; only copy props when you need a draft the user can cancel. +- **Effect fetches or navigates on every dependency tick** — gate with a guard, run on submit/route enter, or track “already loaded” so work runs once per intent. + +When cleanup or reset must follow a specific lifecycle moment, use the matching hook: `onOpenChange` for dialogs, submit/success handlers for forms, `onMount` / load functions for one-time setup. + ### Server-Side API Calls Use `.server.ts` files for server-side code to isolate API keys. @@ -351,6 +414,8 @@ Use `.server.ts` files for server-side code to isolate API keys. - Use non-null assertion (`!`) for `user` when `authMiddleware` is present - Put all user-facing copy in translations and reference by key - Use `SvelteSet`/`SvelteMap` from `svelte/reactivity` for reactive collections (not `new Set`/`new Map`) +- Reset modal/form state in close/submit handlers or `onOpenChange`, not in `$effect` tied to a steady “closed” condition +- Mutate bound `$state` object fields in place when clearing forms (don't reassign the whole object) ### ❌ DON'T - Put business logic in routes or queries @@ -365,6 +430,8 @@ Use `.server.ts` files for server-side code to isolate API keys. - Write plain English strings in components - Use `new Set()`/`new Map()` for mutable reactive state — use `SvelteSet`/`SvelteMap` instead - Wrap `SvelteSet`/`SvelteMap` in `$state()` — they are already reactive +- **Use `$effect` to reset form/modal state whenever a boolean is false** — use `onOpenChange` or explicit handlers on close instead +- **Reassign whole bound state objects to clear forms** (e.g. `fields = {}`) — mutate properties in place - **Use inline type imports** (e.g. `import('Package').Type` in type positions) — use top-level `import type` instead ## Checklist for New Routes diff --git a/apps/dashboard/src/lib/features/ui/course-landing-page/components/payment-modal.svelte b/apps/dashboard/src/lib/features/ui/course-landing-page/components/payment-modal.svelte index 7018faad..bf29fab1 100644 --- a/apps/dashboard/src/lib/features/ui/course-landing-page/components/payment-modal.svelte +++ b/apps/dashboard/src/lib/features/ui/course-landing-page/components/payment-modal.svelte @@ -32,9 +32,24 @@ let step = $state(STEPS.STEP_1); + function resetForm() { + step = STEPS.STEP_1; + fields.fullname = ''; + fields.email = ''; + errors.fullname = ''; + errors.email = ''; + } + + function handleOpenChange(isOpen: boolean) { + open = isOpen; + + if (!isOpen) { + resetForm(); + } + } + async function onSubmit() { errors = coursePaymentValidation(fields); - console.log('coursePayment errors', errors); if (Object.keys(errors).length) { return; @@ -51,13 +66,11 @@ } function onClickPaymentLink() { - fields.fullname = ''; - fields.email = ''; open = false; } - + Process course payment diff --git a/apps/dashboard/src/lib/features/ui/course-landing-page/course-landing-page.svelte b/apps/dashboard/src/lib/features/ui/course-landing-page/course-landing-page.svelte index 8b7d6fb6..434b1a43 100644 --- a/apps/dashboard/src/lib/features/ui/course-landing-page/course-landing-page.svelte +++ b/apps/dashboard/src/lib/features/ui/course-landing-page/course-landing-page.svelte @@ -11,8 +11,11 @@ import type { Course } from '$features/course/utils/types'; import type { AccountOrg } from '$features/app/types'; import UploadWidget from '$features/ui/upload-widget/upload-widget.svelte'; + import PaymentModal from './components/payment-modal.svelte'; import { buildCourseLandingPageProps } from './utils'; import { handleOpenWidget } from './store'; + import { calcCourseDiscount, isCourseFree } from '$lib/utils/functions/course'; + import { capturePosthogEvent } from '$lib/utils/services/posthog'; interface Props { editMode?: boolean; @@ -24,6 +27,8 @@ let { editMode = false, courseData = $bindable(), org = null, themeComponent = null }: Props = $props(); + let openPaymentModal = $state(false); + const activeOrg = $derived(org ?? $currentOrg); const landingSettings = $derived(normalizeLandingPageSettings(activeOrg.landingpage)); @@ -47,11 +52,34 @@ const enrollmentsOpen = $derived(get(courseData, 'metadata.allowNewStudent') === true); const enrollDisabled = $derived(editMode || !enrollmentsOpen); + const discount = $derived(get(courseData, 'metadata.discount', 0)); + const showDiscount = $derived(get(courseData, 'metadata.showDiscount', false)); + const calculatedCost = $derived(calcCourseDiscount(discount, courseData.cost || 0, !!showDiscount)); + const isFree = $derived(isCourseFree(calculatedCost)); + + function handlePaidEnrollClick(event: MouseEvent) { + event.preventDefault(); + + if (editMode || !$currentOrg.siteName) { + return; + } + + capturePosthogEvent('join_course', { + course_id: courseData.id, + course_title: courseData.title, + course_cost: courseData.cost, + course_free: isFree + }); + + openPaymentModal = true; + } + const landingProps = $derived( buildCourseLandingPageProps(courseData, activeOrg, { enrollHref, enrollDisabled, - authAction + authAction, + onPaidEnrollClick: handlePaidEnrollClick }) ); @@ -77,6 +105,12 @@ }); + + {#if ThemeComponent} {#if editMode && $handleOpenWidget.open}
diff --git a/apps/dashboard/src/lib/features/ui/course-landing-page/utils.ts b/apps/dashboard/src/lib/features/ui/course-landing-page/utils.ts index 693847e1..bb5854a0 100644 --- a/apps/dashboard/src/lib/features/ui/course-landing-page/utils.ts +++ b/apps/dashboard/src/lib/features/ui/course-landing-page/utils.ts @@ -7,6 +7,8 @@ import get from 'lodash/get'; import type { AccountOrg } from '$features/app/types'; import { normalizeLandingPageSettings } from '$features/org/utils/landing-page'; import type { CourseLandingPageProps, OrgLandingPageTheme } from '@cio/ui/custom/org-landing-page'; +import { calcCourseDiscount, isCourseFree } from '$lib/utils/functions/course'; +import { t } from '$lib/utils/functions/translations'; export type LandingPageLesson = { id: string; @@ -116,6 +118,7 @@ export function buildCourseLandingPageProps( enrollHref: string; enrollDisabled: boolean; authAction?: { label: string; href: string }; + onPaidEnrollClick?: (event: MouseEvent) => void; } ): CourseLandingPageProps { const landing = normalizeLandingPageSettings(org.landingpage); @@ -161,7 +164,14 @@ export function buildCourseLandingPageProps( hasCertificate ? { label: 'Certificate', value: 'Included' } : null ].filter(Boolean) as Array<{ label: string; value: string }>; - const pricingCtaLabel = course.cost && course.cost > 0 ? 'Enrol now' : 'Start free'; + const discount = metadata?.discount ?? 0; + const showDiscount = metadata?.showDiscount ?? false; + const calculatedCost = calcCourseDiscount(discount, course.cost ?? 0, !!showDiscount); + const isFree = isCourseFree(calculatedCost); + const pricingCtaLabel = isFree + ? t.get('course.navItem.landing_page.pricing_section.enroll') + : t.get('course.navItem.landing_page.pricing_section.buy'); + const paidEnrollClick = !isFree && !options.enrollDisabled ? options.onPaidEnrollClick : undefined; return { theme: landing.theme as OrgLandingPageTheme, @@ -174,7 +184,8 @@ export function buildCourseLandingPageProps( subheading: course.description ?? landing.hero.subheading, primaryAction: { label: pricingCtaLabel, - href: options.enrollHref, + href: paidEnrollClick ? undefined : options.enrollHref, + onclick: paidEnrollClick, disabled: options.enrollDisabled }, secondaryAction: { label: 'View curriculum', href: '#curriculum' }, @@ -237,7 +248,8 @@ export function buildCourseLandingPageProps( discount: metadata?.discount, showDiscount: metadata?.showDiscount, ctaLabel: pricingCtaLabel, - ctaHref: options.enrollHref, + ctaHref: paidEnrollClick ? undefined : options.enrollHref, + ctaOnclick: paidEnrollClick, reward: metadata?.reward?.show ? { show: true, description: metadata.reward.description ?? '' } : undefined }, footer: landing.footer diff --git a/packages/ui/src/custom/org-landing-page/bold/hero.svelte b/packages/ui/src/custom/org-landing-page/bold/hero.svelte index 4530ede0..4ecc15cb 100644 --- a/packages/ui/src/custom/org-landing-page/bold/hero.svelte +++ b/packages/ui/src/custom/org-landing-page/bold/hero.svelte @@ -2,7 +2,7 @@ import type { OrgLandingPageProps } from '../types'; import type { Snippet } from 'svelte'; import SecondaryActionButton from '../secondary-action-button.svelte'; - import { Button } from '../../../base/button'; + import CoursePrimaryAction from '../course-primary-action.svelte'; import EditableLandingSection from '../editable-section.svelte'; interface Props { @@ -29,14 +29,11 @@ {/if} {#if showActions}
- + /> {#if hero.secondaryAction} {/if} diff --git a/packages/ui/src/custom/org-landing-page/classic/hero.svelte b/packages/ui/src/custom/org-landing-page/classic/hero.svelte index 113c3443..f9e8cecf 100644 --- a/packages/ui/src/custom/org-landing-page/classic/hero.svelte +++ b/packages/ui/src/custom/org-landing-page/classic/hero.svelte @@ -2,7 +2,7 @@ import type { OrgLandingPageProps } from '../types'; import type { Snippet } from 'svelte'; import SecondaryActionButton from '../secondary-action-button.svelte'; - import { Button } from '../../../base/button'; + import CoursePrimaryAction from '../course-primary-action.svelte'; import { BlurIn } from '../../animation/blurin'; import { DotPattern } from '../../animation/dot-pattern'; import EditableLandingSection from '../editable-section.svelte'; @@ -41,14 +41,7 @@ {/if} {#if showActions}
- + {#if hero.secondaryAction} - + {#if hero.secondaryAction} {/if} - + {pricing.ctaLabel} diff --git a/packages/ui/src/custom/org-landing-page/course-primary-action.svelte b/packages/ui/src/custom/org-landing-page/course-primary-action.svelte new file mode 100644 index 00000000..0e918e86 --- /dev/null +++ b/packages/ui/src/custom/org-landing-page/course-primary-action.svelte @@ -0,0 +1,24 @@ + + + diff --git a/packages/ui/src/custom/org-landing-page/editorial/hero.svelte b/packages/ui/src/custom/org-landing-page/editorial/hero.svelte index 060a6ec2..48a88c5d 100644 --- a/packages/ui/src/custom/org-landing-page/editorial/hero.svelte +++ b/packages/ui/src/custom/org-landing-page/editorial/hero.svelte @@ -2,7 +2,7 @@ import type { OrgLandingPageProps } from '../types'; import type { Snippet } from 'svelte'; import SecondaryActionButton from '../secondary-action-button.svelte'; - import { Button } from '../../../base/button'; + import CoursePrimaryAction from '../course-primary-action.svelte'; import EditableLandingSection from '../editable-section.svelte'; import { getCourseTypeLandingMeta } from '../landing-page-utils'; @@ -83,14 +83,11 @@ {/if} {#if showActions}
- + /> {#if hero.secondaryAction} - + {#if hero.secondaryAction} - + {#if hero.secondaryAction} {/if} diff --git a/packages/ui/src/custom/org-landing-page/studio/hero.svelte b/packages/ui/src/custom/org-landing-page/studio/hero.svelte index 6e8267e8..11af3d78 100644 --- a/packages/ui/src/custom/org-landing-page/studio/hero.svelte +++ b/packages/ui/src/custom/org-landing-page/studio/hero.svelte @@ -2,7 +2,7 @@ import type { OrgLandingPageProps } from '../types'; import type { Snippet } from 'svelte'; import SecondaryActionButton from '../secondary-action-button.svelte'; - import { Button } from '../../../base/button'; + import CoursePrimaryAction from '../course-primary-action.svelte'; import EditableLandingSection from '../editable-section.svelte'; interface Props { @@ -70,13 +70,7 @@ {/if} {#if showActions}
- + {#if hero.secondaryAction} - + /> {#if hero.secondaryAction} {/if} - + />
{/if} diff --git a/packages/ui/src/custom/org-landing-page/types.ts b/packages/ui/src/custom/org-landing-page/types.ts index 3cc70261..bf02a7bd 100644 --- a/packages/ui/src/custom/org-landing-page/types.ts +++ b/packages/ui/src/custom/org-landing-page/types.ts @@ -214,6 +214,13 @@ export type CourseInstructor = { coursesNo?: number; }; +export type LandingPrimaryAction = { + label: string; + href?: string; + disabled?: boolean; + onclick?: (event: MouseEvent) => void; +}; + export type CoursePricing = { cost: number; currency: string; @@ -221,6 +228,7 @@ export type CoursePricing = { showDiscount?: boolean; ctaLabel: string; ctaHref?: string; + ctaOnclick?: (event: MouseEvent) => void; features?: string[]; reward?: { show: boolean; @@ -307,7 +315,7 @@ export interface CourseLandingPageProps { hero: { heading: string; subheading: string; - primaryAction: { label: string; href: string; disabled?: boolean }; + primaryAction: LandingPrimaryAction; secondaryAction?: { label: string; href: string }; image?: string; stats?: Array<{ label: string; value: string }>; @@ -339,7 +347,7 @@ export interface OrgLandingPageProps { hero: { heading: string; subheading: string; - primaryAction: { label: string; href: string; disabled?: boolean }; + primaryAction: LandingPrimaryAction; secondaryAction?: { label: string; href: string }; image?: string; stats?: Array<{ label: string; value: string }>; diff --git a/packages/ui/src/custom/org-landing-page/vibrant/hero.svelte b/packages/ui/src/custom/org-landing-page/vibrant/hero.svelte index 2296fbd3..7d4f6a41 100644 --- a/packages/ui/src/custom/org-landing-page/vibrant/hero.svelte +++ b/packages/ui/src/custom/org-landing-page/vibrant/hero.svelte @@ -2,7 +2,7 @@ import type { OrgLandingPageProps } from '../types'; import type { Snippet } from 'svelte'; import SecondaryActionButton from '../secondary-action-button.svelte'; - import { Button } from '../../../base/button'; + import CoursePrimaryAction from '../course-primary-action.svelte'; import EditableLandingSection from '../editable-section.svelte'; interface Props { @@ -45,13 +45,10 @@ {/if} {#if showActions}
- + /> {#if hero.secondaryAction}