Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
2 changes: 1 addition & 1 deletion apps/web/client/src/app/_components/top-bar/github.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { Icons } from '@onlook/ui/icons';
import { useEffect, useState } from 'react';

const DEFAULT_STAR_COUNT = 20550;
const DEFAULT_STAR_COUNT = 22000;
const DEFAULT_CONTRIBUTORS_COUNT = 90;

const formatStarCount = (count: number): string => {
Expand Down
5 changes: 4 additions & 1 deletion apps/web/client/src/components/ui/avatar-dropdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { getInitials } from '@onlook/utility';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useState } from 'react';
import { UsageSection } from './plans';
import { SettingsTabValue } from '../settings-modal/helpers';

export const CurrentUserAvatar = ({ className }: { className?: string }) => {
const stateManager = useStateManager();
Expand All @@ -40,11 +41,13 @@ export const CurrentUserAvatar = ({ className }: { className?: string }) => {
};

const handleOpenSubscription = () => {
stateManager.isSubscriptionModalOpen = true;
stateManager.settingsTab = SettingsTabValue.SUBSCRIPTION;
stateManager.isSettingsModalOpen = true;
setOpen(false);
};

const handleOpenSettings = () => {
stateManager.settingsTab = SettingsTabValue.PREFERENCES;
stateManager.isSettingsModalOpen = true;
setOpen(false);
};
Expand Down
3 changes: 2 additions & 1 deletion apps/web/client/src/components/ui/settings-modal/helpers.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
export enum SettingsTabValue {
DOMAIN = 'domain',
PROJECT = 'project',
PREFERENCES = 'preferences',
PREFERENCES = 'account',
SUBSCRIPTION = 'subscription',
VERSIONS = 'versions',
ADVANCED = 'advanced',
SITE = 'site',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { AnimatePresence, motion } from 'framer-motion';
import { observer } from 'mobx-react-lite';
import { SettingsTabValue, type SettingTab } from './helpers';
import { PreferencesTab } from './preferences-tab';
import { SubscriptionTab } from './subscription-tab';

export const NonProjectSettingsModal = observer(() => {
const stateManager = useStateManager();
Expand All @@ -18,6 +19,11 @@ export const NonProjectSettingsModal = observer(() => {
icon: <Icons.Person className="mr-2 h-4 w-4" />,
component: <PreferencesTab />,
},
{
label: SettingsTabValue.SUBSCRIPTION,
icon: <Icons.CreditCard className="mr-2 h-4 w-4" />,
component: <SubscriptionTab />,
},
]

return (
Expand Down
116 changes: 2 additions & 114 deletions apps/web/client/src/components/ui/settings-modal/preferences-tab.tsx
Original file line number Diff line number Diff line change
@@ -1,122 +1,10 @@
import { api } from '@/trpc/react';
import { Button } from '@onlook/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@onlook/ui/dropdown-menu';
import { Icons } from '@onlook/ui/icons';
import { observer } from 'mobx-react-lite';
import { UserDeleteSection } from './user-delete-section';

export const PreferencesTab = observer(() => {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

🧩 Analysis chain

Confirm client boundary exists for this tab

tRPC React hooks (and MobX observer if retained) require a client component boundary above. Verify the parent settings modal or entry file has 'use client'.

Run:


🏁 Script executed:

#!/bin/bash
# Verify a client boundary exists in settings modal tree
rg -n --type=ts,tsx -C1 $'^[ \t]*[\'"]use client[\'"]' apps/web/client/src/components/ui/settings-modal
# Also check modal entry points mentioned in the PR
rg -n --type=ts,tsx -C1 $'^[ \t]*[\'"]use client[\'"]' apps/web/client/src/components/ui/settings-modal/{non-project.tsx,with-project.tsx} 2>/dev/null || true

Length of output: 30


🏁 Script executed:

#!/bin/bash
# Verify client boundary exists in settings modal tree (search for 'use client' in ts/tsx)
dir="apps/web/client/src/components/ui/settings-modal"
echo "Checking directory: $dir"
if [ ! -d "$dir" ]; then
  echo "DIR_NOT_FOUND"
  exit 0
fi
echo "Listing files in $dir"
ls -la "$dir"
echo "Searching for 'use client' in .ts/.tsx under $dir"
grep -nR --include='*.tsx' --include='*.ts' -E "^\s*['\"]use client['\"]" "$dir" -n -H -C1 || true
echo "Also checking specific entry files (non-project.tsx, with-project.tsx)"
for f in non-project.tsx with-project.tsx; do
  p="$dir/$f"
  if [ -f "$p" ]; then
    echo "---- $p (first 120 lines) ----"
    sed -n '1,120p' "$p"
    echo "grep result:"
    grep -nE "^\s*['\"]use client['\"]" "$p" || true
  else
    echo "MISSING: $p"
  fi
done

Length of output: 11899


Add 'use client' to the settings modal entry components

PreferencesTab is a MobX observer (and may use tRPC hooks); the modal entry files lack a client boundary—add a top-line "use client" to one of these entry components (or otherwise ensure a higher client boundary):

  • apps/web/client/src/components/ui/settings-modal/non-project.tsx
  • apps/web/client/src/components/ui/settings-modal/with-project.tsx
🤖 Prompt for AI Agents
In apps/web/client/src/components/ui/settings-modal/preferences-tab.tsx around
line 4, the PreferencesTab is a MobX observer and may use client-only hooks but
the modal entry components lack a client boundary; add a top-line "use client"
directive to one of the entry components (preferably both) —
apps/web/client/src/components/ui/settings-modal/non-project.tsx and
apps/web/client/src/components/ui/settings-modal/with-project.tsx — or ensure a
higher-level parent is already a client component, so the observer and any
tRPC/hooks run in a client boundary.

const { data: settings } = api.user.settings.get.useQuery();
const { mutate: updateSettings } = api.user.settings.upsert.useMutation();
const shouldWarnDelete = settings?.editor?.shouldWarnDelete ?? true;

async function updateDeleteWarning(enabled: boolean) {
await updateSettings({ shouldWarnDelete: enabled });
}

return (
<div className="flex flex-col gap-8 p-6">
{/* <div className="flex justify-between items-center">
<div className="flex flex-col gap-2">
<p className="text-largePlus">Language</p>
<p className="text-foreground-onlook text-small">
Choose your preferred language
</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="text-smallPlus min-w-[150px]">
{LANGUAGE_DISPLAY_NAMES[
locale as keyof typeof LANGUAGE_DISPLAY_NAMES
] ?? 'English'}
<Icons.ChevronDown className="ml-auto" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[150px]">
{Object.entries(LANGUAGE_DISPLAY_NAMES).map(([code, name]) => (
<DropdownMenuItem
key={code}
onClick={() => userManager.language.update(code as Language)}
>
<span>{name}</span>
{locale === code && <Icons.CheckCircled className="ml-auto" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex justify-between items-center">
<div className="flex flex-col gap-2">
<p className="text-largePlus">Theme</p>
<p className="text-foreground-onlook text-small">
Choose your preferred appearance
</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="text-smallPlus min-w-[150px]">
{theme === SystemTheme.DARK && <Icons.Moon className="mr-2 h-4 w-4" />}
{theme === SystemTheme.LIGHT && <Icons.Sun className="mr-2 h-4 w-4" />}
{theme === SystemTheme.SYSTEM && (
<Icons.Laptop className="mr-2 h-4 w-4" />
)}
<span className="capitalize">{theme}</span>
<Icons.ChevronDown className="ml-auto" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[150px]">
<DropdownMenuItem onClick={() => setTheme(SystemTheme.LIGHT)}>
<Icons.Sun className="mr-2 h-4 w-4" />
<span>Light</span>
{theme === SystemTheme.LIGHT && (
<Icons.CheckCircled className="ml-auto" />
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme(SystemTheme.DARK)}>
<Icons.Moon className="mr-2 h-4 w-4" />
<span>Dark</span>
{theme === SystemTheme.DARK && (
<Icons.CheckCircled className="ml-auto" />
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme(SystemTheme.SYSTEM)}>
<Icons.Laptop className="mr-2 h-4 w-4" />
<span>System</span>
{theme === SystemTheme.SYSTEM && (
<Icons.CheckCircled className="ml-auto" />
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> */}
<div className=" flex justify-between items-center gap-4">
<div className=" flex flex-col gap-2">
<p className="text-largePlus">{'Warn before delete'}</p>
<p className="text-foreground-onlook text-small">
{'This adds a warning before deleting elements in the editor'}
</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="text-smallPlus min-w-[150px]">
{shouldWarnDelete ? 'On' : 'Off'}
<Icons.ChevronDown className="ml-auto" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="text-smallPlus min-w-[150px]">
<DropdownMenuItem onClick={() => updateDeleteWarning(true)}>
{'Warning On'}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => updateDeleteWarning(false)}>
{'Warning Off'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<UserDeleteSection />
</div>
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
'use client';

import { Button } from '@onlook/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@onlook/ui/dialog';

interface SubscriptionCancelModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirmCancel: () => void;
}

export const SubscriptionCancelModal = ({ open, onOpenChange, onConfirmCancel }: SubscriptionCancelModalProps) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Cancel Subscription</DialogTitle>
<DialogDescription className="pt-2">
Are you sure you want to cancel your subscription? You'll lose access to all premium features at the end of your current billing period.
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex-col sm:flex-row gap-3 sm:gap-2">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="order-2 sm:order-1"
>
Keep Subscription
</Button>
<Button
variant="outline"
onClick={onConfirmCancel}
className="order-1 sm:order-2 text-red-200 hover:text-red-100 hover:bg-red-500/10 border-red-200 hover:border-red-100"
>
Cancel Subscription
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
160 changes: 160 additions & 0 deletions apps/web/client/src/components/ui/settings-modal/subscription-tab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
'use client';

import { useStateManager } from '@/components/store/state';
import { api } from '@/trpc/react';
import { ScheduledSubscriptionAction } from '@onlook/stripe';
import { Button } from '@onlook/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@onlook/ui/dropdown-menu';
import { Icons } from '@onlook/ui/icons';
import { Separator } from '@onlook/ui/separator';
import { toast } from '@onlook/ui/sonner';
import { observer } from 'mobx-react-lite';
import { useState } from 'react';
import { useSubscription } from '../pricing-modal/use-subscription';
import { SubscriptionCancelModal } from './subscription-cancel-modal';

export const SubscriptionTab = observer(() => {
const stateManager = useStateManager();
const { subscription, isPro } = useSubscription();
const [showCancelModal, setShowCancelModal] = useState(false);
const [isManageDropdownOpen, setIsManageDropdownOpen] = useState(false);
const [isLoadingPortal, setIsLoadingPortal] = useState(false);

const manageSubscriptionMutation = api.subscription.manageSubscription.useMutation({
onSuccess: (session) => {
if (session?.url) {
window.open(session.url, '_blank');
}
},
onError: (error) => {
console.error('Failed to create portal session:', error);
toast.error('Failed to create portal session');
},
onSettled: () => {
setIsLoadingPortal(false);
}
});

const handleUpgradePlan = () => {
stateManager.isSubscriptionModalOpen = true;
stateManager.isSettingsModalOpen = false;
setIsManageDropdownOpen(false);
};

const handleCancelSubscription = () => {
setShowCancelModal(true);
setIsManageDropdownOpen(false);
};

const handleConfirmCancel = () => {
// Cancellation logic will be implemented later
setShowCancelModal(false);
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The subscription cancellation implementation is incomplete. The handleConfirmCancel function only closes the modal without actually canceling the subscription. This creates a misleading user experience where users might believe they've canceled their subscription when they haven't. Consider implementing the actual cancellation logic before shipping this feature, or clearly indicating to users that this functionality is coming soon if it must be shipped in this state.

Suggested change
const handleConfirmCancel = () => {
// Cancellation logic will be implemented later
setShowCancelModal(false);
};
const handleConfirmCancel = async () => {
try {
// Call the subscription cancellation API
await cancelUserSubscription();
// Show success message to user
toast.success("Your subscription has been successfully canceled");
// Close the modal
setShowCancelModal(false);
} catch (error) {
// Handle errors
console.error("Failed to cancel subscription:", error);
toast.error("Failed to cancel your subscription. Please try again or contact support.");
}
};

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.


const handleManageBilling = async () => {
if (isPro && subscription) {
setIsLoadingPortal(true);
await manageSubscriptionMutation.mutateAsync();
}
};

return (
<div className="flex flex-col p-8">
{/* Subscription Section */}
<div className="space-y-6">
<div>
<h2 className="text-title3 mb-2">Subscription</h2>
<p className="text-muted-foreground text-small">
Manage your subscription plan and billing
</p>
</div>

<div className="space-y-4">
<div className="flex items-center justify-between py-4">
<div className="space-y-1">
<p className="text-regularPlus font-medium">Current Plan</p>
<p className="text-small text-muted-foreground">
{isPro ? (
subscription?.scheduledChange?.scheduledAction === ScheduledSubscriptionAction.CANCELLATION ? (
<>Pro plan (cancelling on {subscription.scheduledChange.scheduledChangeAt.toLocaleDateString()})</>
Comment on lines +85 to +86
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential null pointer exception when accessing subscription.scheduledChange.scheduledChangeAt. If scheduledChange exists but scheduledChangeAt is null/undefined, calling toLocaleDateString() will throw a runtime error. Add null checking: subscription.scheduledChange?.scheduledChangeAt?.toLocaleDateString()

Suggested change
subscription?.scheduledChange?.scheduledAction === ScheduledSubscriptionAction.CANCELLATION ? (
<>Pro plan (cancelling on {subscription.scheduledChange.scheduledChangeAt.toLocaleDateString()})</>
subscription?.scheduledChange?.scheduledAction === ScheduledSubscriptionAction.CANCELLATION ? (
<>Pro plan (cancelling on {subscription.scheduledChange?.scheduledChangeAt?.toLocaleDateString()})</>

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

) : (
<>Pro plan - {subscription?.price?.monthlyMessageLimit || 'Unlimited'} messages per month</>
)
) : (
'You are currently on the Free plan'
)}
</p>
</div>
<DropdownMenu open={isManageDropdownOpen} onOpenChange={setIsManageDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
Manage
<Icons.ChevronDown className="ml-1 h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{!isPro && (
<DropdownMenuItem
onClick={handleUpgradePlan}
className="cursor-pointer"
>
<Icons.Sparkles className="mr-2 h-4 w-4" />
Upgrade plan
</DropdownMenuItem>
)}
{isPro && subscription?.scheduledChange?.scheduledAction !== ScheduledSubscriptionAction.CANCELLATION && (
<DropdownMenuItem
onClick={handleUpgradePlan}
className="cursor-pointer"
>
<Icons.Sparkles className="mr-2 h-4 w-4" />
Change plan
</DropdownMenuItem>
)}
{isPro && (
<DropdownMenuItem
onClick={handleCancelSubscription}
className="cursor-pointer text-red-200 hover:text-red-100 group"
>
<Icons.CrossS className="mr-2 h-4 w-4 text-red-200 group-hover:text-red-100" />
{subscription?.scheduledChange?.scheduledAction === ScheduledSubscriptionAction.CANCELLATION ? 'Reactivate subscription' : 'Cancel subscription'}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>

<Separator />

{/* Payment Section */}
<div className="flex items-center justify-between py-4">
<div className="space-y-1">
<p className="text-regularPlus font-medium">Payment</p>
<p className="text-small text-muted-foreground">
Manage your payment methods and billing details
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleManageBilling}
disabled={isLoadingPortal || !isPro}
>
{isLoadingPortal ? 'Opening...' : 'Manage'}
</Button>
</div>
</div>
</div>
<SubscriptionCancelModal
open={showCancelModal}
onOpenChange={setShowCancelModal}
onConfirmCancel={handleConfirmCancel}
/>
</div>
);
});
Loading