feat: migrate from lemonsqueezy to polar (#535)
* feat: move from lemonsqueezy to polar * update lock * add migration files * test with demo product id
This commit is contained in:
@@ -1,32 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import type { OrganizationPlan } from '$lib/utils/types';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { goto } from '$app/navigation';
|
||||
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
|
||||
import { page } from '$app/stores';
|
||||
import Checkmark from 'carbon-icons-svelte/lib/Checkmark.svelte';
|
||||
import PLANS from 'shared/src/plans/data.json';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { subscribeToProduct } from '$lib/utils/services/lemonsqueezy/subscribe';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { Loading } from 'carbon-components-svelte';
|
||||
import Modal from '$lib/components/Modal/index.svelte';
|
||||
import StepDoneIcon from '$lib/components/Icons/StepDoneIcon.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import type { RealtimeChannel, RealtimePostgresChangesPayload } from '@supabase/supabase-js';
|
||||
import Confetti from '$lib/components/Confetti/index.svelte';
|
||||
import { toggleConfetti } from '$lib/components/Confetti/store';
|
||||
|
||||
import StepDoneIcon from '$lib/components/Icons/StepDoneIcon.svelte';
|
||||
import Modal from '$lib/components/Modal/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrg, currentOrgPath, isFreePlan } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { Loading } from 'carbon-components-svelte';
|
||||
import Checkmark from 'carbon-icons-svelte/lib/Checkmark.svelte';
|
||||
import PLANS from 'shared/src/plans/data.json';
|
||||
|
||||
const disabledClass = 'bg-gray-300 text-gray-400 hover:cursor-not-allowed';
|
||||
|
||||
const planNames = Object.keys(PLANS);
|
||||
|
||||
let isLoadingPlan: string | null = null;
|
||||
let orgPlanChannel: RealtimeChannel;
|
||||
let open = false;
|
||||
let upgraded = false;
|
||||
let isYearlyPlan = false;
|
||||
let isConfirming = false;
|
||||
|
||||
async function handleClick(plan, planName: string) {
|
||||
if (plan.CTA.IS_DISABLED || !$profile.id) {
|
||||
@@ -41,20 +39,24 @@
|
||||
isLoadingPlan = planName;
|
||||
|
||||
try {
|
||||
const { checkoutURL } = await subscribeToProduct({
|
||||
productId: isYearlyPlan ? plan.CTA.PRODUCT_ID_YEARLY : plan.CTA.PRODUCT_ID,
|
||||
email: $profile.email,
|
||||
name: $profile.fullname,
|
||||
triggeredBy: $currentOrg.memberId,
|
||||
orgId: $currentOrg.id
|
||||
});
|
||||
const checkoutURL = new URL('/api/polar/subscribe', $page.url);
|
||||
|
||||
if (!checkoutURL) {
|
||||
snackbar.error('snackbar.upgrade.generate_fail');
|
||||
return;
|
||||
}
|
||||
checkoutURL.searchParams.set(
|
||||
'productId',
|
||||
isYearlyPlan ? plan.CTA.PRODUCT_ID_YEARLY : plan.CTA.PRODUCT_ID
|
||||
);
|
||||
checkoutURL.searchParams.set('customerEmail', $profile.email);
|
||||
checkoutURL.searchParams.set('customerName', $profile.fullname);
|
||||
checkoutURL.searchParams.set(
|
||||
'metadata',
|
||||
JSON.stringify({
|
||||
triggeredBy: $currentOrg.memberId,
|
||||
orgId: $currentOrg.id,
|
||||
orgSlug: $currentOrg.siteName
|
||||
})
|
||||
);
|
||||
|
||||
window.open(checkoutURL, '_blank');
|
||||
window.location.href = checkoutURL.toString();
|
||||
} catch (error) {
|
||||
console.error('Error subscribing', error);
|
||||
|
||||
@@ -64,27 +66,18 @@
|
||||
isLoadingPlan = null;
|
||||
}
|
||||
|
||||
async function handleInsert(payload: RealtimePostgresChangesPayload<OrganizationPlan>) {
|
||||
const newPlan = payload.new as OrganizationPlan;
|
||||
function onUpgrade() {
|
||||
upgraded = true;
|
||||
toggleConfetti();
|
||||
|
||||
console.log('A new plan was inserted');
|
||||
// If plan was successfully generated
|
||||
if (newPlan.org_id === $currentOrg.id && newPlan.is_active) {
|
||||
upgraded = true;
|
||||
setTimeout(() => {
|
||||
toggleConfetti();
|
||||
|
||||
setTimeout(() => {
|
||||
toggleConfetti();
|
||||
}, 1000);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
if (upgraded) {
|
||||
window.location.href = $currentOrgPath;
|
||||
} else {
|
||||
goto($page.url.pathname);
|
||||
}
|
||||
const path = upgraded ? $currentOrgPath : $page.url.pathname;
|
||||
goto(path);
|
||||
}
|
||||
|
||||
function onLearnMore() {
|
||||
@@ -94,28 +87,18 @@
|
||||
isYearlyPlan = !isYearlyPlan;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
orgPlanChannel = supabase
|
||||
.channel('any')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: 'INSERT', schema: 'public', table: 'organization_plan' },
|
||||
handleInsert
|
||||
)
|
||||
.subscribe();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (orgPlanChannel) {
|
||||
supabase.removeChannel(orgPlanChannel);
|
||||
}
|
||||
});
|
||||
|
||||
$: {
|
||||
const query = new URLSearchParams($page.url.search);
|
||||
open = (query.get('upgrade') || '') === 'true';
|
||||
isConfirming = (query.get('confirmation') || '') === 'true';
|
||||
}
|
||||
|
||||
$: handleUpgradeSuccess(Boolean($currentOrg.id && !$isFreePlan && open));
|
||||
function handleUpgradeSuccess(upgradeSuccessful: boolean) {
|
||||
if (upgradeSuccessful) {
|
||||
onUpgrade();
|
||||
}
|
||||
}
|
||||
$: planNames = Object.keys(PLANS);
|
||||
</script>
|
||||
|
||||
{#if upgraded}
|
||||
@@ -126,12 +109,12 @@
|
||||
{onClose}
|
||||
{open}
|
||||
width="w-4/5"
|
||||
maxWidth={upgraded ? 'max-w-[600px]' : undefined}
|
||||
maxWidth={upgraded || isConfirming ? 'max-w-[600px]' : undefined}
|
||||
modalHeading={$t('pricing.modal.heading')}
|
||||
containerClass="pt-4"
|
||||
>
|
||||
{#if upgraded}
|
||||
<div class="flex flex-col items-center justify-center mb-4 w-full relative">
|
||||
<div class="relative mb-4 flex w-full flex-col items-center justify-center">
|
||||
<StepDoneIcon />
|
||||
<h4 class="text-2xl">{$t('pricing.modal.thanks')}</h4>
|
||||
<p class="mb-4 text-center">
|
||||
@@ -150,6 +133,10 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else if isConfirming}
|
||||
<div class="relative mb-4 flex w-full flex-col items-center justify-center">
|
||||
<Loading withOverlay={false} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<div class="relative mb-2 flex items-center rounded-[30px] border-[2px] p-[2px] lg:scale-100">
|
||||
@@ -157,7 +144,7 @@
|
||||
style="background-color: {isYearlyPlan ? 'initial' : '#1D4EE2'}; color: {isYearlyPlan
|
||||
? '#5e636b'
|
||||
: '#fff'}"
|
||||
class="rounded-[30px] bg-blue-700 px-3 py-1 text-xs text-white lg:px-4 lg:py-2 transition-all duration-500 ease-in-out"
|
||||
class="rounded-[30px] bg-blue-700 px-3 py-1 text-xs text-white transition-all duration-500 ease-in-out lg:px-4 lg:py-2"
|
||||
on:click={toggleIsYearlyPlan}
|
||||
>
|
||||
{$t('pricing.modal.monthly')}
|
||||
@@ -166,12 +153,12 @@
|
||||
style="background-color: {isYearlyPlan ? '#1D4EE2' : ''}; color: {isYearlyPlan
|
||||
? '#fff'
|
||||
: '#5e636b'}"
|
||||
class="relative rounded-[30px] px-3 py-1 text-xs text-white lg:px-4 lg:py-2 transition-all duration-500 ease-in-out"
|
||||
class="relative rounded-[30px] px-3 py-1 text-xs text-white transition-all duration-500 ease-in-out lg:px-4 lg:py-2"
|
||||
on:click={toggleIsYearlyPlan}
|
||||
>
|
||||
{$t('pricing.modal.annually')}
|
||||
<div
|
||||
class="absolute right-[-40%] -top-4 scale-[90%] rounded-full bg-[#006600] px-1.5 py-1 text-[0.7rem] text-white"
|
||||
class="absolute -top-4 right-[-40%] scale-[90%] rounded-full bg-[#006600] px-1.5 py-1 text-[0.7rem] text-white"
|
||||
>
|
||||
{$t('pricing.modal.save')}
|
||||
</div>
|
||||
@@ -213,7 +200,7 @@
|
||||
? 'bg-white text-slate-900 hover:bg-indigo-50'
|
||||
: PLANS[planName].CTA.IS_DISABLED
|
||||
? disabledClass
|
||||
: 'bg-primary-900 hover:bg-primary-700 text-white'} py-3 text-center font-medium hover:no-underline lg:rounded-md lg:py-3 lg:text-lg lg:font-semibold flex items-center justify-center"
|
||||
: 'bg-primary-900 hover:bg-primary-700 text-white'} flex items-center justify-center py-3 text-center font-medium hover:no-underline lg:rounded-md lg:py-3 lg:text-lg lg:font-semibold"
|
||||
on:click={() => {
|
||||
if (isLoadingPlan === planName) return;
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ export async function getOrganizations(userId: string, isOrgSite?: boolean, orgS
|
||||
organization_plan(
|
||||
plan_name,
|
||||
is_active,
|
||||
subscriptionId:lmz_data->id
|
||||
subscriptionId:payload->id
|
||||
)
|
||||
)
|
||||
`
|
||||
@@ -255,11 +255,27 @@ export async function getCurrentOrg(siteName: string, justGet = false, isCustomD
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateOrgPlan(params: {
|
||||
supabase: typeof supabase;
|
||||
subscriptionId: string;
|
||||
data: OrganizationPlan['payload'];
|
||||
}) {
|
||||
return await params.supabase
|
||||
.from('organization_plan')
|
||||
.update({
|
||||
payload: params.data
|
||||
})
|
||||
.match({
|
||||
subscription_id: params.subscriptionId
|
||||
});
|
||||
}
|
||||
|
||||
export async function createOrgPlan(params: {
|
||||
orgId: string;
|
||||
planName: string;
|
||||
triggeredBy: number;
|
||||
data: OrganizationPlan['lmz_data'];
|
||||
orgId: OrganizationPlan['org_id'];
|
||||
planName: OrganizationPlan['plan_name'];
|
||||
subscriptionId: OrganizationPlan['subscription_id'];
|
||||
triggeredBy: OrganizationPlan['triggered_by'];
|
||||
data: OrganizationPlan['payload'];
|
||||
supabase: typeof supabase;
|
||||
}) {
|
||||
return await params.supabase.from('organization_plan').insert({
|
||||
@@ -268,19 +284,24 @@ export async function createOrgPlan(params: {
|
||||
triggered_by: params.triggeredBy,
|
||||
plan_name: params.planName,
|
||||
is_active: true,
|
||||
lmz_data: params.data
|
||||
payload: params.data,
|
||||
subscription_id: params.subscriptionId,
|
||||
provider: 'polar'
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelOrgPlan(params: { orgId: string; planName: string }) {
|
||||
export async function cancelOrgPlan(params: {
|
||||
subscriptionId: string;
|
||||
data: OrganizationPlan['payload'];
|
||||
}) {
|
||||
return await supabase
|
||||
.from('organization_plan')
|
||||
.update({
|
||||
is_active: false,
|
||||
deactivated_at: new Date().toDateString()
|
||||
deactivated_at: new Date().toDateString(),
|
||||
payload: params.data
|
||||
})
|
||||
.match({
|
||||
plan_name: params.planName,
|
||||
org_id: params.orgId
|
||||
subscription_id: params.subscriptionId
|
||||
});
|
||||
}
|
||||
|
||||
@@ -136,10 +136,13 @@ export interface OrganizationPlan {
|
||||
activated_at: string;
|
||||
deactivated_at: string;
|
||||
org_id: string;
|
||||
subscription_id: string;
|
||||
triggered_by: number;
|
||||
plan_name: string;
|
||||
is_active: boolean;
|
||||
updated_at: string;
|
||||
lmz_data: unknown;
|
||||
payload: unknown;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
interface BillingAddress {
|
||||
line1: string | null;
|
||||
line2: string | null;
|
||||
postalCode: string | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
country: string;
|
||||
}
|
||||
|
||||
interface Customer {
|
||||
createdAt: string;
|
||||
modifiedAt: string | null;
|
||||
id: string;
|
||||
metadata: Record<string, any>;
|
||||
email: string;
|
||||
emailVerified: boolean;
|
||||
name: string;
|
||||
billingAddress: BillingAddress;
|
||||
taxId: string | null;
|
||||
organizationId: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
publicName: string;
|
||||
avatarUrl: string | null;
|
||||
githubUsername: string | null;
|
||||
}
|
||||
|
||||
interface Price {
|
||||
createdAt: string;
|
||||
modifiedAt: string | null;
|
||||
id: string;
|
||||
amountType: string;
|
||||
isArchived: boolean;
|
||||
productId: string;
|
||||
priceCurrency: string;
|
||||
priceAmount: number;
|
||||
type: string;
|
||||
recurringInterval: string;
|
||||
}
|
||||
|
||||
interface Media {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
name: string;
|
||||
path: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
storageVersion: string;
|
||||
checksumEtag: string;
|
||||
checksumSha256Base64: string;
|
||||
checksumSha256Hex: string;
|
||||
lastModifiedAt: string;
|
||||
version: string | null;
|
||||
service: string;
|
||||
isUploaded: boolean;
|
||||
createdAt: string;
|
||||
sizeReadable: string;
|
||||
publicUrl: string;
|
||||
}
|
||||
|
||||
interface Product {
|
||||
createdAt: string;
|
||||
modifiedAt: string;
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
isRecurring: boolean;
|
||||
isArchived: boolean;
|
||||
organizationId: string;
|
||||
metadata: Record<string, any>;
|
||||
prices: Price[];
|
||||
benefits: any[];
|
||||
medias: Media[];
|
||||
attachedCustomFields: any[];
|
||||
}
|
||||
|
||||
interface SubscriptionData {
|
||||
createdAt: string;
|
||||
modifiedAt: string | null;
|
||||
id: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
recurringInterval: string;
|
||||
status: string;
|
||||
currentPeriodStart: string;
|
||||
currentPeriodEnd: string;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
canceledAt: string | null;
|
||||
startedAt: string;
|
||||
endsAt: string | null;
|
||||
endedAt: string | null;
|
||||
customerId: string;
|
||||
productId: string;
|
||||
priceId: string;
|
||||
discountId: string | null;
|
||||
checkoutId: string;
|
||||
customerCancellationReason: string | null;
|
||||
customerCancellationComment: string | null;
|
||||
metadata: {
|
||||
orgId: string;
|
||||
orgSlug: string;
|
||||
triggeredBy: string;
|
||||
};
|
||||
customFieldData: Record<string, any>;
|
||||
customer: Customer;
|
||||
userId: string;
|
||||
user: User;
|
||||
product: Product;
|
||||
price: Price;
|
||||
discount: any | null;
|
||||
}
|
||||
|
||||
export interface PolarWebhookPayload {
|
||||
type: string;
|
||||
data: SubscriptionData;
|
||||
}
|
||||
Reference in New Issue
Block a user