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 <[email protected]> * fix(dashboard): reset payment modal on close without effect loop Co-authored-by: Rotimi Best <[email protected]> * docs: add Svelte 5 rerender and effect loop guidance to AGENTS.md Co-authored-by: Rotimi Best <[email protected]> * fix(dashboard): avoid double-applying discount on course landing pricing Co-authored-by: Rotimi Best <[email protected]> --------- Co-authored-by: Cursor Agent <[email protected]> Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
co-authored by
Rotimi Best
Cursor Agent
parent
fd8bec3e70
commit
46feccd0fd
@@ -320,6 +320,69 @@ In Svelte 5, the built-in `Set` and `Map` classes are **not** reactive. Use `Sve
|
|||||||
</script>
|
</script>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
<Dialog.Root {open} onOpenChange={handleOpenChange}>
|
||||||
|
```
|
||||||
|
|
||||||
|
**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
|
### Server-Side API Calls
|
||||||
|
|
||||||
Use `.server.ts` files for server-side code to isolate API keys.
|
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
|
- Use non-null assertion (`!`) for `user` when `authMiddleware` is present
|
||||||
- Put all user-facing copy in translations and reference by key
|
- 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`)
|
- 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
|
### ❌ DON'T
|
||||||
- Put business logic in routes or queries
|
- 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
|
- Write plain English strings in components
|
||||||
- Use `new Set()`/`new Map()` for mutable reactive state — use `SvelteSet`/`SvelteMap` instead
|
- Use `new Set()`/`new Map()` for mutable reactive state — use `SvelteSet`/`SvelteMap` instead
|
||||||
- Wrap `SvelteSet`/`SvelteMap` in `$state()` — they are already reactive
|
- 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
|
- **Use inline type imports** (e.g. `import('Package').Type` in type positions) — use top-level `import type` instead
|
||||||
|
|
||||||
## Checklist for New Routes
|
## Checklist for New Routes
|
||||||
|
|||||||
+17
-4
@@ -32,9 +32,24 @@
|
|||||||
|
|
||||||
let step = $state(STEPS.STEP_1);
|
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() {
|
async function onSubmit() {
|
||||||
errors = coursePaymentValidation(fields);
|
errors = coursePaymentValidation(fields);
|
||||||
console.log('coursePayment errors', errors);
|
|
||||||
|
|
||||||
if (Object.keys(errors).length) {
|
if (Object.keys(errors).length) {
|
||||||
return;
|
return;
|
||||||
@@ -51,13 +66,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onClickPaymentLink() {
|
function onClickPaymentLink() {
|
||||||
fields.fullname = '';
|
|
||||||
fields.email = '';
|
|
||||||
open = false;
|
open = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog.Root bind:open>
|
<Dialog.Root {open} onOpenChange={handleOpenChange}>
|
||||||
<Dialog.Content class="w-96">
|
<Dialog.Content class="w-96">
|
||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
<Dialog.Title>Process course payment</Dialog.Title>
|
<Dialog.Title>Process course payment</Dialog.Title>
|
||||||
|
|||||||
@@ -11,8 +11,11 @@
|
|||||||
import type { Course } from '$features/course/utils/types';
|
import type { Course } from '$features/course/utils/types';
|
||||||
import type { AccountOrg } from '$features/app/types';
|
import type { AccountOrg } from '$features/app/types';
|
||||||
import UploadWidget from '$features/ui/upload-widget/upload-widget.svelte';
|
import UploadWidget from '$features/ui/upload-widget/upload-widget.svelte';
|
||||||
|
import PaymentModal from './components/payment-modal.svelte';
|
||||||
import { buildCourseLandingPageProps } from './utils';
|
import { buildCourseLandingPageProps } from './utils';
|
||||||
import { handleOpenWidget } from './store';
|
import { handleOpenWidget } from './store';
|
||||||
|
import { calcCourseDiscount, isCourseFree } from '$lib/utils/functions/course';
|
||||||
|
import { capturePosthogEvent } from '$lib/utils/services/posthog';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
editMode?: boolean;
|
editMode?: boolean;
|
||||||
@@ -24,6 +27,8 @@
|
|||||||
|
|
||||||
let { editMode = false, courseData = $bindable(), org = null, themeComponent = null }: Props = $props();
|
let { editMode = false, courseData = $bindable(), org = null, themeComponent = null }: Props = $props();
|
||||||
|
|
||||||
|
let openPaymentModal = $state(false);
|
||||||
|
|
||||||
const activeOrg = $derived(org ?? $currentOrg);
|
const activeOrg = $derived(org ?? $currentOrg);
|
||||||
const landingSettings = $derived(normalizeLandingPageSettings(activeOrg.landingpage));
|
const landingSettings = $derived(normalizeLandingPageSettings(activeOrg.landingpage));
|
||||||
|
|
||||||
@@ -47,11 +52,34 @@
|
|||||||
const enrollmentsOpen = $derived(get(courseData, 'metadata.allowNewStudent') === true);
|
const enrollmentsOpen = $derived(get(courseData, 'metadata.allowNewStudent') === true);
|
||||||
const enrollDisabled = $derived(editMode || !enrollmentsOpen);
|
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(
|
const landingProps = $derived(
|
||||||
buildCourseLandingPageProps(courseData, activeOrg, {
|
buildCourseLandingPageProps(courseData, activeOrg, {
|
||||||
enrollHref,
|
enrollHref,
|
||||||
enrollDisabled,
|
enrollDisabled,
|
||||||
authAction
|
authAction,
|
||||||
|
onPaidEnrollClick: handlePaidEnrollClick
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -77,6 +105,12 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<PaymentModal
|
||||||
|
bind:open={openPaymentModal}
|
||||||
|
paymentLink={get(courseData, 'metadata.paymentLink', '')}
|
||||||
|
courseId={courseData.id}
|
||||||
|
/>
|
||||||
|
|
||||||
{#if ThemeComponent}
|
{#if ThemeComponent}
|
||||||
{#if editMode && $handleOpenWidget.open}
|
{#if editMode && $handleOpenWidget.open}
|
||||||
<div class="mx-auto w-full max-w-7xl px-6 py-3">
|
<div class="mx-auto w-full max-w-7xl px-6 py-3">
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import get from 'lodash/get';
|
|||||||
import type { AccountOrg } from '$features/app/types';
|
import type { AccountOrg } from '$features/app/types';
|
||||||
import { normalizeLandingPageSettings } from '$features/org/utils/landing-page';
|
import { normalizeLandingPageSettings } from '$features/org/utils/landing-page';
|
||||||
import type { CourseLandingPageProps, OrgLandingPageTheme } from '@cio/ui/custom/org-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 = {
|
export type LandingPageLesson = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -116,6 +118,7 @@ export function buildCourseLandingPageProps(
|
|||||||
enrollHref: string;
|
enrollHref: string;
|
||||||
enrollDisabled: boolean;
|
enrollDisabled: boolean;
|
||||||
authAction?: { label: string; href: string };
|
authAction?: { label: string; href: string };
|
||||||
|
onPaidEnrollClick?: (event: MouseEvent) => void;
|
||||||
}
|
}
|
||||||
): CourseLandingPageProps {
|
): CourseLandingPageProps {
|
||||||
const landing = normalizeLandingPageSettings(org.landingpage);
|
const landing = normalizeLandingPageSettings(org.landingpage);
|
||||||
@@ -161,7 +164,14 @@ export function buildCourseLandingPageProps(
|
|||||||
hasCertificate ? { label: 'Certificate', value: 'Included' } : null
|
hasCertificate ? { label: 'Certificate', value: 'Included' } : null
|
||||||
].filter(Boolean) as Array<{ label: string; value: string }>;
|
].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 {
|
return {
|
||||||
theme: landing.theme as OrgLandingPageTheme,
|
theme: landing.theme as OrgLandingPageTheme,
|
||||||
@@ -174,7 +184,8 @@ export function buildCourseLandingPageProps(
|
|||||||
subheading: course.description ?? landing.hero.subheading,
|
subheading: course.description ?? landing.hero.subheading,
|
||||||
primaryAction: {
|
primaryAction: {
|
||||||
label: pricingCtaLabel,
|
label: pricingCtaLabel,
|
||||||
href: options.enrollHref,
|
href: paidEnrollClick ? undefined : options.enrollHref,
|
||||||
|
onclick: paidEnrollClick,
|
||||||
disabled: options.enrollDisabled
|
disabled: options.enrollDisabled
|
||||||
},
|
},
|
||||||
secondaryAction: { label: 'View curriculum', href: '#curriculum' },
|
secondaryAction: { label: 'View curriculum', href: '#curriculum' },
|
||||||
@@ -237,7 +248,8 @@ export function buildCourseLandingPageProps(
|
|||||||
discount: metadata?.discount,
|
discount: metadata?.discount,
|
||||||
showDiscount: metadata?.showDiscount,
|
showDiscount: metadata?.showDiscount,
|
||||||
ctaLabel: pricingCtaLabel,
|
ctaLabel: pricingCtaLabel,
|
||||||
ctaHref: options.enrollHref,
|
ctaHref: paidEnrollClick ? undefined : options.enrollHref,
|
||||||
|
ctaOnclick: paidEnrollClick,
|
||||||
reward: metadata?.reward?.show ? { show: true, description: metadata.reward.description ?? '' } : undefined
|
reward: metadata?.reward?.show ? { show: true, description: metadata.reward.description ?? '' } : undefined
|
||||||
},
|
},
|
||||||
footer: landing.footer
|
footer: landing.footer
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import type { OrgLandingPageProps } from '../types';
|
import type { OrgLandingPageProps } from '../types';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import SecondaryActionButton from '../secondary-action-button.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 EditableLandingSection from '../editable-section.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -29,14 +29,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if showActions}
|
{#if showActions}
|
||||||
<div class="ui:flex ui:flex-wrap ui:gap-4">
|
<div class="ui:flex ui:flex-wrap ui:gap-4">
|
||||||
<Button
|
<CoursePrimaryAction
|
||||||
href={hero.primaryAction.href}
|
action={hero.primaryAction}
|
||||||
disabled={hero.primaryAction.disabled ?? false}
|
|
||||||
size="lg"
|
size="lg"
|
||||||
class="ui:rounded-xl ui:px-8 ui:py-6 ui:text-base ui:font-bold ui:shadow-lg ui:shadow-[var(--landing-accent)]/20 ui:hover:-translate-y-1 ui:transition-all"
|
class="ui:rounded-xl ui:px-8 ui:py-6 ui:text-base ui:font-bold ui:shadow-lg ui:shadow-[var(--landing-accent)]/20 ui:hover:-translate-y-1 ui:transition-all"
|
||||||
>
|
/>
|
||||||
{hero.primaryAction.label}
|
|
||||||
</Button>
|
|
||||||
{#if hero.secondaryAction}
|
{#if hero.secondaryAction}
|
||||||
<SecondaryActionButton href={hero.secondaryAction.href} label={hero.secondaryAction.label} variant="bold" />
|
<SecondaryActionButton href={hero.secondaryAction.href} label={hero.secondaryAction.label} variant="bold" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import type { OrgLandingPageProps } from '../types';
|
import type { OrgLandingPageProps } from '../types';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import SecondaryActionButton from '../secondary-action-button.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 { BlurIn } from '../../animation/blurin';
|
||||||
import { DotPattern } from '../../animation/dot-pattern';
|
import { DotPattern } from '../../animation/dot-pattern';
|
||||||
import EditableLandingSection from '../editable-section.svelte';
|
import EditableLandingSection from '../editable-section.svelte';
|
||||||
@@ -41,14 +41,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if showActions}
|
{#if showActions}
|
||||||
<div class="ui:flex ui:flex-col ui:sm:flex-row ui:justify-center ui:gap-4">
|
<div class="ui:flex ui:flex-col ui:sm:flex-row ui:justify-center ui:gap-4">
|
||||||
<Button
|
<CoursePrimaryAction action={hero.primaryAction} size="lg" class="ui:px-8 ui:font-semibold" />
|
||||||
href={hero.primaryAction.href}
|
|
||||||
disabled={hero.primaryAction.disabled ?? false}
|
|
||||||
size="lg"
|
|
||||||
class="ui:px-8 ui:font-semibold"
|
|
||||||
>
|
|
||||||
{hero.primaryAction.label}
|
|
||||||
</Button>
|
|
||||||
{#if hero.secondaryAction}
|
{#if hero.secondaryAction}
|
||||||
<SecondaryActionButton
|
<SecondaryActionButton
|
||||||
href={hero.secondaryAction.href}
|
href={hero.secondaryAction.href}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import type { OrgLandingPageProps } from '../types';
|
import type { OrgLandingPageProps } from '../types';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import SecondaryActionButton from '../secondary-action-button.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 EditableLandingSection from '../editable-section.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -57,13 +57,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if showActions}
|
{#if showActions}
|
||||||
<div class="ui:flex ui:flex-wrap ui:items-center ui:gap-3">
|
<div class="ui:flex ui:flex-wrap ui:items-center ui:gap-3">
|
||||||
<Button
|
<CoursePrimaryAction action={hero.primaryAction} class="ui:rounded-none ui:px-6 ui:font-medium" />
|
||||||
href={hero.primaryAction.href}
|
|
||||||
disabled={hero.primaryAction.disabled ?? false}
|
|
||||||
class="ui:rounded-none ui:px-6 ui:font-medium"
|
|
||||||
>
|
|
||||||
{hero.primaryAction.label}
|
|
||||||
</Button>
|
|
||||||
{#if hero.secondaryAction}
|
{#if hero.secondaryAction}
|
||||||
<SecondaryActionButton
|
<SecondaryActionButton
|
||||||
href={hero.secondaryAction.href}
|
href={hero.secondaryAction.href}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ export function alignHeroCtaWithPricing(
|
|||||||
...hero,
|
...hero,
|
||||||
primaryAction: {
|
primaryAction: {
|
||||||
label: pricing.ctaLabel,
|
label: pricing.ctaLabel,
|
||||||
href: pricing.ctaHref ?? '#pricing',
|
href: pricing.ctaOnclick ? undefined : (pricing.ctaHref ?? '#pricing'),
|
||||||
|
onclick: pricing.ctaOnclick,
|
||||||
disabled: hero.primaryAction.disabled
|
disabled: hero.primaryAction.disabled
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -71,7 +71,13 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<LandingButton variant="primary" size="lg" href={pricing.ctaHref ?? '#enroll'} class={t.pricingCta}>
|
<LandingButton
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
href={pricing.ctaOnclick ? undefined : (pricing.ctaHref ?? '#enroll')}
|
||||||
|
onclick={pricing.ctaOnclick}
|
||||||
|
class={t.pricingCta}
|
||||||
|
>
|
||||||
{pricing.ctaLabel}
|
{pricing.ctaLabel}
|
||||||
</LandingButton>
|
</LandingButton>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button, type ButtonVariant } from '../../base/button';
|
||||||
|
import type { LandingPrimaryAction } from './types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
action: LandingPrimaryAction;
|
||||||
|
size?: 'default' | 'sm' | 'lg';
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { action, size = 'default', variant = 'default', class: className = '' }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
href={action.onclick ? undefined : action.href}
|
||||||
|
onclick={action.onclick}
|
||||||
|
disabled={action.disabled ?? false}
|
||||||
|
{size}
|
||||||
|
{variant}
|
||||||
|
class={className}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import type { OrgLandingPageProps } from '../types';
|
import type { OrgLandingPageProps } from '../types';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import SecondaryActionButton from '../secondary-action-button.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 EditableLandingSection from '../editable-section.svelte';
|
||||||
import { getCourseTypeLandingMeta } from '../landing-page-utils';
|
import { getCourseTypeLandingMeta } from '../landing-page-utils';
|
||||||
|
|
||||||
@@ -83,14 +83,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if showActions}
|
{#if showActions}
|
||||||
<div class="ui:flex ui:flex-wrap ui:items-center ui:gap-2.5 {featured ? '' : 'ui:justify-center'}">
|
<div class="ui:flex ui:flex-wrap ui:items-center ui:gap-2.5 {featured ? '' : 'ui:justify-center'}">
|
||||||
<Button
|
<CoursePrimaryAction
|
||||||
href={hero.primaryAction.href}
|
action={hero.primaryAction}
|
||||||
disabled={hero.primaryAction.disabled ?? false}
|
|
||||||
size="lg"
|
size="lg"
|
||||||
class="ui:rounded-full ui:px-6 ui:font-medium ui:bg-[var(--landing-fg)] ui:text-[var(--landing-bg)] ui:hover:opacity-90"
|
class="ui:rounded-full ui:px-6 ui:font-medium ui:bg-[var(--landing-fg)] ui:text-[var(--landing-bg)] ui:hover:opacity-90"
|
||||||
>
|
/>
|
||||||
{hero.primaryAction.label}
|
|
||||||
</Button>
|
|
||||||
{#if hero.secondaryAction}
|
{#if hero.secondaryAction}
|
||||||
<SecondaryActionButton
|
<SecondaryActionButton
|
||||||
href={hero.secondaryAction.href}
|
href={hero.secondaryAction.href}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import type { OrgLandingPageProps } from '../types';
|
import type { OrgLandingPageProps } from '../types';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import SecondaryActionButton from '../secondary-action-button.svelte';
|
import SecondaryActionButton from '../secondary-action-button.svelte';
|
||||||
import { Button } from '../../../base/button';
|
import CoursePrimaryAction from '../course-primary-action.svelte';
|
||||||
import { DotPattern } from '../../animation/dot-pattern';
|
import { DotPattern } from '../../animation/dot-pattern';
|
||||||
import { ShinnyText } from '../../animation/shinny-text';
|
import { ShinnyText } from '../../animation/shinny-text';
|
||||||
import EditableLandingSection from '../editable-section.svelte';
|
import EditableLandingSection from '../editable-section.svelte';
|
||||||
@@ -55,14 +55,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if showActions}
|
{#if showActions}
|
||||||
<div class="ui:flex ui:items-center ui:justify-center ui:gap-4">
|
<div class="ui:flex ui:items-center ui:justify-center ui:gap-4">
|
||||||
<Button
|
<CoursePrimaryAction action={hero.primaryAction} size="lg" class="ui:rounded-full" />
|
||||||
href={hero.primaryAction.href}
|
|
||||||
disabled={hero.primaryAction.disabled ?? false}
|
|
||||||
size="lg"
|
|
||||||
class="ui:rounded-full"
|
|
||||||
>
|
|
||||||
{hero.primaryAction.label}
|
|
||||||
</Button>
|
|
||||||
{#if hero.secondaryAction}
|
{#if hero.secondaryAction}
|
||||||
<SecondaryActionButton
|
<SecondaryActionButton
|
||||||
href={hero.secondaryAction.href}
|
href={hero.secondaryAction.href}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import type { OrgLandingPageProps } from '../types';
|
import type { OrgLandingPageProps } from '../types';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import SecondaryActionButton from '../secondary-action-button.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 EditableLandingSection from '../editable-section.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -44,14 +44,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if showActions}
|
{#if showActions}
|
||||||
<div class="ui:flex ui:items-center ui:justify-center ui:gap-2.5">
|
<div class="ui:flex ui:items-center ui:justify-center ui:gap-2.5">
|
||||||
<Button
|
<CoursePrimaryAction action={hero.primaryAction} size="lg" class="ui:rounded-full ui:px-6" />
|
||||||
href={hero.primaryAction.href}
|
|
||||||
disabled={hero.primaryAction.disabled ?? false}
|
|
||||||
size="lg"
|
|
||||||
class="ui:rounded-full ui:px-6"
|
|
||||||
>
|
|
||||||
{hero.primaryAction.label}
|
|
||||||
</Button>
|
|
||||||
{#if hero.secondaryAction}
|
{#if hero.secondaryAction}
|
||||||
<SecondaryActionButton href={hero.secondaryAction.href} label={hero.secondaryAction.label} variant="saas" />
|
<SecondaryActionButton href={hero.secondaryAction.href} label={hero.secondaryAction.label} variant="saas" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import type { OrgLandingPageProps } from '../types';
|
import type { OrgLandingPageProps } from '../types';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import SecondaryActionButton from '../secondary-action-button.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 EditableLandingSection from '../editable-section.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -70,13 +70,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if showActions}
|
{#if showActions}
|
||||||
<div class="ui:flex ui:flex-wrap ui:items-center ui:gap-2">
|
<div class="ui:flex ui:flex-wrap ui:items-center ui:gap-2">
|
||||||
<Button
|
<CoursePrimaryAction action={hero.primaryAction} class="ui:rounded-md ui:px-5" />
|
||||||
href={hero.primaryAction.href}
|
|
||||||
disabled={hero.primaryAction.disabled ?? false}
|
|
||||||
class="ui:rounded-md ui:px-5"
|
|
||||||
>
|
|
||||||
{hero.primaryAction.label}
|
|
||||||
</Button>
|
|
||||||
{#if hero.secondaryAction}
|
{#if hero.secondaryAction}
|
||||||
<SecondaryActionButton
|
<SecondaryActionButton
|
||||||
href={hero.secondaryAction.href}
|
href={hero.secondaryAction.href}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import type { OrgLandingPageProps } from '../types';
|
import type { OrgLandingPageProps } from '../types';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import SecondaryActionButton from '../secondary-action-button.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 EditableLandingSection from '../editable-section.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -57,15 +57,12 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if showActions}
|
{#if showActions}
|
||||||
<div class="ui:flex ui:flex-wrap ui:items-center ui:gap-3">
|
<div class="ui:flex ui:flex-wrap ui:items-center ui:gap-3">
|
||||||
<Button
|
<CoursePrimaryAction
|
||||||
href={hero.primaryAction.href}
|
action={hero.primaryAction}
|
||||||
disabled={hero.primaryAction.disabled ?? false}
|
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
class="ui:rounded-none ui:px-7 ui:font-semibold ui:bg-[var(--landing-accent-fg)] ui:text-[var(--landing-accent)] ui:hover:bg-[var(--landing-accent-fg)]/90"
|
class="ui:rounded-none ui:px-7 ui:font-semibold ui:bg-[var(--landing-accent-fg)] ui:text-[var(--landing-accent)] ui:hover:bg-[var(--landing-accent-fg)]/90"
|
||||||
>
|
/>
|
||||||
{hero.primaryAction.label}
|
|
||||||
</Button>
|
|
||||||
{#if hero.secondaryAction}
|
{#if hero.secondaryAction}
|
||||||
<SecondaryActionButton
|
<SecondaryActionButton
|
||||||
href={hero.secondaryAction.href}
|
href={hero.secondaryAction.href}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import type { OrgLandingPageProps, CourseItem } from '../types';
|
import type { OrgLandingPageProps, CourseItem } from '../types';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { Button } from '../../../base/button';
|
import { Button } from '../../../base/button';
|
||||||
|
import CoursePrimaryAction from '../course-primary-action.svelte';
|
||||||
import EditableLandingSection from '../editable-section.svelte';
|
import EditableLandingSection from '../editable-section.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -107,14 +108,11 @@
|
|||||||
{hero.secondaryAction.label}
|
{hero.secondaryAction.label}
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
<Button
|
<CoursePrimaryAction
|
||||||
href={hero.primaryAction.href}
|
action={hero.primaryAction}
|
||||||
disabled={hero.primaryAction.disabled ?? false}
|
|
||||||
size="sm"
|
size="sm"
|
||||||
class="ui:rounded-full ui:px-4 ui:font-medium ui:bg-[#f4f5f7] ui:text-[#0a0b0e] ui:hover:bg-white"
|
class="ui:rounded-full ui:px-4 ui:font-medium ui:bg-[#f4f5f7] ui:text-[#0a0b0e] ui:hover:bg-white"
|
||||||
>
|
/>
|
||||||
{hero.primaryAction.label}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -214,6 +214,13 @@ export type CourseInstructor = {
|
|||||||
coursesNo?: number;
|
coursesNo?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type LandingPrimaryAction = {
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onclick?: (event: MouseEvent) => void;
|
||||||
|
};
|
||||||
|
|
||||||
export type CoursePricing = {
|
export type CoursePricing = {
|
||||||
cost: number;
|
cost: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
@@ -221,6 +228,7 @@ export type CoursePricing = {
|
|||||||
showDiscount?: boolean;
|
showDiscount?: boolean;
|
||||||
ctaLabel: string;
|
ctaLabel: string;
|
||||||
ctaHref?: string;
|
ctaHref?: string;
|
||||||
|
ctaOnclick?: (event: MouseEvent) => void;
|
||||||
features?: string[];
|
features?: string[];
|
||||||
reward?: {
|
reward?: {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
@@ -307,7 +315,7 @@ export interface CourseLandingPageProps {
|
|||||||
hero: {
|
hero: {
|
||||||
heading: string;
|
heading: string;
|
||||||
subheading: string;
|
subheading: string;
|
||||||
primaryAction: { label: string; href: string; disabled?: boolean };
|
primaryAction: LandingPrimaryAction;
|
||||||
secondaryAction?: { label: string; href: string };
|
secondaryAction?: { label: string; href: string };
|
||||||
image?: string;
|
image?: string;
|
||||||
stats?: Array<{ label: string; value: string }>;
|
stats?: Array<{ label: string; value: string }>;
|
||||||
@@ -339,7 +347,7 @@ export interface OrgLandingPageProps {
|
|||||||
hero: {
|
hero: {
|
||||||
heading: string;
|
heading: string;
|
||||||
subheading: string;
|
subheading: string;
|
||||||
primaryAction: { label: string; href: string; disabled?: boolean };
|
primaryAction: LandingPrimaryAction;
|
||||||
secondaryAction?: { label: string; href: string };
|
secondaryAction?: { label: string; href: string };
|
||||||
image?: string;
|
image?: string;
|
||||||
stats?: Array<{ label: string; value: string }>;
|
stats?: Array<{ label: string; value: string }>;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import type { OrgLandingPageProps } from '../types';
|
import type { OrgLandingPageProps } from '../types';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import SecondaryActionButton from '../secondary-action-button.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 EditableLandingSection from '../editable-section.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -45,13 +45,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if showActions}
|
{#if showActions}
|
||||||
<div class="ui:inline-flex ui:flex-wrap ui:justify-center ui:items-center ui:gap-3">
|
<div class="ui:inline-flex ui:flex-wrap ui:justify-center ui:items-center ui:gap-3">
|
||||||
<Button
|
<CoursePrimaryAction
|
||||||
href={hero.primaryAction.href}
|
action={hero.primaryAction}
|
||||||
disabled={hero.primaryAction.disabled ?? false}
|
|
||||||
class="ui:rounded-md ui:px-7 ui:py-3.5 ui:text-base ui:font-medium ui:bg-[var(--landing-accent)] ui:text-[var(--landing-accent-fg)] ui:hover:opacity-90"
|
class="ui:rounded-md ui:px-7 ui:py-3.5 ui:text-base ui:font-medium ui:bg-[var(--landing-accent)] ui:text-[var(--landing-accent-fg)] ui:hover:opacity-90"
|
||||||
>
|
/>
|
||||||
{hero.primaryAction.label}
|
|
||||||
</Button>
|
|
||||||
{#if hero.secondaryAction}
|
{#if hero.secondaryAction}
|
||||||
<SecondaryActionButton
|
<SecondaryActionButton
|
||||||
href={hero.secondaryAction.href}
|
href={hero.secondaryAction.href}
|
||||||
|
|||||||
Reference in New Issue
Block a user