Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion e2e/license-flows.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,11 @@ test.describe('Existing license holder data accuracy', () => {
// downloads link stays, alongside a renew CTA.
const renewLink = card.getByRole('link', { name: 'Renew' })
await expect(renewLink).toBeVisible()
await expect(renewLink).toHaveAttribute('href', '/pro')
// Yearly licence → Renew deep-links to the pre-filled yearly checkout.
await expect(renewLink).toHaveAttribute(
'href',
'/pro/checkout?product=wcpos-pro-yearly'
)
await expect(card.getByRole('link', { name: /Downloads/ })).toBeVisible()
})

Expand Down
33 changes: 31 additions & 2 deletions src/components/account/licenses-client.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { fireEvent, screen } from '@testing-library/react'
import { renderWithIntl as render } from '@/test/intl'
import { LicensesClient } from './licenses-client'
import type { CanonicalLicenseStatus } from '@/lib/license-status'
import { DEFAULT_YEARLY_POLICY_ID } from '@/lib/plans'

// Mock the locale-aware Link as a simple anchor
vi.mock('@/i18n/navigation', () => ({
Expand Down Expand Up @@ -226,7 +227,11 @@ describe('LicensesClient', () => {
expect(
screen.queryByText(/renew to keep receiving updates/)
).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'Renew' })).not.toBeInTheDocument()
// The expiry banner is gone, but the always-visible footer Renew remains.
expect(screen.getByRole('link', { name: 'Renew' })).toHaveAttribute(
'href',
'/pro'
)
})

it('does not warn for lifetime licenses', () => {
Expand All @@ -239,20 +244,44 @@ describe('LicensesClient', () => {
expect(
screen.queryByText(/renew to keep receiving updates/)
).not.toBeInTheDocument()
// A lifetime licence never expires — there is nothing to renew.
expect(
screen.queryByRole('link', { name: 'Renew' })
).not.toBeInTheDocument()
})

it('labels a yearly-policy license "Yearly"', () => {
render(
<LicensesClient
initialLicenses={[
makeLicense({ policyId: '261cb7e2-6e80-476e-98bd-fe7f406f258d' }),
makeLicense({ policyId: DEFAULT_YEARLY_POLICY_ID }),
]}
/>
)

expect(screen.getByText('Yearly')).toBeInTheDocument()
})

it('deep-links the Renew button to the pre-filled yearly checkout', () => {
render(
<LicensesClient
initialLicenses={[
makeLicense({
status: 'active',
policyId: DEFAULT_YEARLY_POLICY_ID,
}),
]}
/>
)

// An active, non-expiring yearly licence shows the always-visible Renew,
// deep-linked to the pre-filled yearly checkout (not the generic /pro).
expect(screen.getByRole('link', { name: 'Renew' })).toHaveAttribute(
'href',
'/pro/checkout?product=wcpos-pro-yearly'
)
})

it('does NOT label an unregistered/unknown policy as "Lifetime"', () => {
render(
<LicensesClient
Expand Down
82 changes: 54 additions & 28 deletions src/components/account/licenses-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -224,11 +224,6 @@ export function LicensesClient({
const getDisplayStatus = (license: License) =>
getLicenseDisplayStatus(license, now)

const getPlanLabel = (policyId: string) => {
const plan = getPlanByPolicyId(policyId)
return plan ? t(plan.labelKey) : null
}

// When another active license (e.g. a lifetime one) keeps update access
// open beyond the warning window, the per-card notice drops the "renew to
// keep receiving updates" urgency — updates are not actually at risk.
Expand Down Expand Up @@ -256,7 +251,26 @@ export function LicensesClient({
const displayStatus = getDisplayStatus(license)
const statusPresentation = presentLicenseStatus(displayStatus)
const expiringSoon = isLicenseExpiringSoon(license, now)
const planLabel = getPlanLabel(license.policyId)
const plan = getPlanByPolicyId(license.policyId)
const planLabel = plan ? t(plan.labelKey) : null
// A licence is renewable when it has an expiry to extend; a lifetime
// licence (null expiry) never renews. Deep-link to the pre-filled
// yearly checkout when the plan handle resolves (else the generic
// /pro). On payment the Medusa order-completed subscriber extends the
// SAME licence using the locked max(expiry, now) + 1yr rule.
const isRenewable = license.expiry != null
const renewHref = plan?.handle
? `/pro/checkout?product=${plan.handle}`
Comment thread
kilbot marked this conversation as resolved.
: '/pro'
// Always offer renewal on a renewable licence that is active or
// expired, EXCEPT while the expiring-soon banner is already showing
// its own Renew (avoids two identical CTAs on one card). Skip the
// exceptional states (suspended/revoked/unknown) where a charge would
// not restore access.
const showRenew =
isRenewable &&
!expiringSoon &&
(displayStatus === 'active' || displayStatus === 'expired')
// Per-licence attributed version (ADR-0006): null when this licence
// alone covers nothing.
const coveredVersion = entitledVersions[license.id] ?? null
Expand Down Expand Up @@ -347,7 +361,7 @@ export function LicensesClient({
<AccountNotice
action={
<Button asChild size="sm">
<Link href="/pro">{t('renew')}</Link>
<Link href={renewHref}>{t('renew')}</Link>
</Button>
}
>
Expand Down Expand Up @@ -412,28 +426,40 @@ export function LicensesClient({
</span>
</div>
</div>
{displayStatus === 'active' && (
<Button asChild size="sm">
<Link href="/account/downloads">
<Download className="mr-2 h-4 w-4" />
{t('downloads')}
</Link>
</Button>
)}
{displayStatus === 'expired' && (
{(displayStatus === 'active' ||
displayStatus === 'expired') && (
<div className="flex items-center gap-2">
<Button asChild size="sm">
<Link href="/pro">{t('renew')}</Link>
</Button>
{/* Expired licenses can still download versions released
before their expiry; scope the downloads page to this
licence so another active licence does not pool access. */}
<Button asChild size="sm" variant="outline">
<Link href={scopedDownloadsHref}>
<Download className="mr-2 h-4 w-4" />
{t('downloads')}
</Link>
</Button>
{/* Renew is always offered on a yearly licence: primary when
expired (needed to regain access), secondary when active
(an optional early renewal — no days are lost). */}
{showRenew && (
<Button
asChild
size="sm"
variant={displayStatus === 'active' ? 'outline' : 'default'}
>
<Link href={renewHref}>{t('renew')}</Link>
Comment thread
kilbot marked this conversation as resolved.
</Button>
)}
{displayStatus === 'active' && (
<Button asChild size="sm">
<Link href="/account/downloads">
<Download className="mr-2 h-4 w-4" />
{t('downloads')}
</Link>
</Button>
)}
{displayStatus === 'expired' && (
/* Expired licenses can still download versions released
before their expiry; scope the downloads page to this
licence so another active licence does not pool access. */
<Button asChild size="sm" variant="outline">
<Link href={scopedDownloadsHref}>
<Download className="mr-2 h-4 w-4" />
{t('downloads')}
</Link>
</Button>
)}
</div>
)}
</div>
Expand Down
Loading