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
48 changes: 24 additions & 24 deletions .github/workflows/pr-target-check.yml
Original file line number Diff line number Diff line change
@@ -1,29 +1,29 @@
name: Redirect PRs to dev

on:
pull_request_target:
branches:
- main
pull_request_target:
branches:
- main

jobs:
close-and-redirect:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Close PR and redirect to dev
uses: actions/github-script@v7
with:
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: "👋 Thanks for your contribution! We don't accept pull requests directly to `main`. Please re-open your PR targeting the `dev` branch instead. See our contributing guide for details."
});
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
state: 'closed'
});
close-and-redirect:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Close PR and redirect to dev
uses: actions/github-script@v7
with:
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: "👋 Thanks for your contribution! We don't accept pull requests directly to `main`. Please re-open your PR targeting the `dev` branch instead. See our contributing guide for details."
});
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
state: 'closed'
});
129 changes: 96 additions & 33 deletions src/components/common/NotificationBell.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { Bell } from 'lucide-react';
import {
Bell,
ArrowRightLeft,
Clock,
TrendingUp,
UserPlus,
Key,
} from 'lucide-react';
import { useNavigate } from 'react-router';
import { cn } from '@/lib/utils';
import { useNotifications } from '@/hooks/useNotifications';
import { formatRelativeTime } from '@/utils/time.utils';
import type { NotificationType } from '@/services/notification.service';
import {
DropdownMenu,
DropdownMenuContent,
Expand All @@ -18,24 +26,88 @@ interface NotificationBellProps {
className?: string;
}

function renderNotificationIcon(type: NotificationType) {
switch (type) {
case 'trade_completed':
return (
<ArrowRightLeft
className="size-4 shrink-0 text-emerald-400"
data-testid="icon-trade_completed"
aria-hidden="true"
/>
);
case 'lockup_expiring':
return (
<Clock
className="size-4 shrink-0 text-amber-400"
data-testid="icon-lockup_expiring"
aria-hidden="true"
/>
);
case 'price_moved':
return (
<TrendingUp
className="size-4 shrink-0 text-cyan-400"
data-testid="icon-price_moved"
aria-hidden="true"
/>
);
case 'new_follower':
return (
<UserPlus
className="size-4 shrink-0 text-purple-400"
data-testid="icon-new_follower"
aria-hidden="true"
/>
);
case 'key_purchase':
return (
<Key
className="size-4 shrink-0 text-blue-400"
data-testid="icon-key_purchase"
aria-hidden="true"
/>
);
case 'price_milestone':
default:
return (
<Bell
className="size-4 shrink-0 text-amber-400"
data-testid="icon-default"
aria-hidden="true"
/>
);
}
}

/**
* Notification bell for the nav bar (issue #720).
* Notification bell and global drawer for the nav bar (issues #720, #800).
*
* Shows an unread-count badge when count > 0, opens a dropdown of the five
* most recent notifications, marks items read on click, and provides a
* "View all" link to /notifications.
* Shows an unread-count badge when count > 0, opens a drawer/dropdown of recent
* notifications sorted newest first, renders distinct icons per event type,
* marks all notifications read on open, and clears the badge count.
*/
export function NotificationBell({ userId, className = '' }: NotificationBellProps) {
export function NotificationBell({
userId,
className = '',
}: NotificationBellProps) {
const navigate = useNavigate();
const { recent, unreadCount, isLoading, markAsRead } = useNotifications(userId);
const { recent, unreadCount, isLoading, markAsRead, markAllAsRead } =
useNotifications(userId);

const handleOpenChange = (open: boolean) => {
if (open && unreadCount > 0 && typeof markAllAsRead === 'function') {
markAllAsRead();
}
};

const handleNotificationClick = (notificationId: string, href: string) => {
markAsRead(notificationId);
void navigate(href);
};

return (
<DropdownMenu>
<DropdownMenu onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<button
type="button"
Expand Down Expand Up @@ -65,29 +137,26 @@ export function NotificationBell({ userId, className = '' }: NotificationBellPro

<DropdownMenuContent
align="end"
className="w-80"
className="w-80 border-white/10 bg-[#0b1728] text-white shadow-2xl"
data-testid="notification-dropdown"
>
<DropdownMenuLabel className="flex items-center justify-between">
<DropdownMenuLabel className="flex items-center justify-between font-grotesque font-bold text-white">
<span>Notifications</span>
{unreadCount > 0 && (
<span className="ml-2 rounded-full bg-red-500/15 px-2 py-0.5 text-xs font-semibold text-red-500">
<span className="ml-2 rounded-full bg-red-500/15 px-2 py-0.5 text-xs font-semibold text-red-400">
{unreadCount} unread
</span>
)}
</DropdownMenuLabel>

<DropdownMenuSeparator />
<DropdownMenuSeparator className="bg-white/10" />

{isLoading && (
<div
data-testid="notification-loading"
className="space-y-2 p-2"
>
<div data-testid="notification-loading" className="space-y-2 p-2">
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className="h-12 w-full animate-pulse rounded-md bg-muted"
className="h-12 w-full animate-pulse rounded-md bg-white/10"
/>
))}
</div>
Expand All @@ -96,7 +165,7 @@ export function NotificationBell({ userId, className = '' }: NotificationBellPro
{!isLoading && recent.length === 0 && (
<p
data-testid="notification-empty-state"
className="px-3 py-6 text-center text-sm text-muted-foreground"
className="px-3 py-6 text-center text-sm text-white/50"
>
No notifications yet
</p>
Expand All @@ -108,8 +177,8 @@ export function NotificationBell({ userId, className = '' }: NotificationBellPro
key={notification.id}
data-testid={`notification-item-${notification.id}`}
className={cn(
'flex cursor-pointer flex-col items-start gap-1 px-3 py-2.5',
!notification.read && 'bg-accent/40'
'flex cursor-pointer flex-col items-start gap-1 px-3 py-2.5 hover:bg-white/10 focus:bg-white/10',
!notification.read && 'bg-white/[0.05]'
)}
onSelect={() =>
handleNotificationClick(
Expand All @@ -118,36 +187,30 @@ export function NotificationBell({ userId, className = '' }: NotificationBellPro
)
}
>
<div className="flex w-full items-start gap-2">
{/* Unread indicator dot */}
{!notification.read && (
<span
aria-label="Unread"
className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-blue-500"
/>
)}
<div className="flex w-full items-start gap-2.5">
{renderNotificationIcon(notification.type)}
<span
className={cn(
'flex-1 text-sm leading-snug',
notification.read
? 'text-muted-foreground'
: 'font-medium text-foreground'
? 'text-white/60'
: 'font-medium text-white'
)}
>
{notification.message}
</span>
</div>
<span className="pl-4 text-xs text-muted-foreground">
<span className="pl-6 text-xs text-white/40">
{formatRelativeTime(notification.createdAt)}
</span>
</DropdownMenuItem>
))}

<DropdownMenuSeparator />
<DropdownMenuSeparator className="bg-white/10" />

<DropdownMenuItem
data-testid="notification-view-all"
className="justify-center text-sm font-medium text-primary hover:text-primary"
className="justify-center text-sm font-semibold text-amber-400 hover:text-amber-300 focus:text-amber-300"
onSelect={() => void navigate('/notifications')}
>
View all
Expand Down
66 changes: 66 additions & 0 deletions src/components/common/ShareTwitterButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Share2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
buildShareTweetText,
buildTwitterIntentUrl,
} from '@/utils/shareTwitter.utils';

export interface ShareTwitterButtonProps {
creatorId: string;
creatorName: string;
priceXlm: string | number;
userAddress?: string | null;
userHoldingsCount?: number;
className?: string;
}



export function ShareTwitterButton({
creatorId,
creatorName,
priceXlm,
userAddress,
userHoldingsCount = 0,
className = '',
}: ShareTwitterButtonProps) {
// Show Share button only when user is authenticated and holds at least 1 key
const isAuthenticated = Boolean(userAddress && userAddress.trim() !== '');
const isHolder = userHoldingsCount > 0;

if (!isAuthenticated || !isHolder) {
return null;
}

const origin =
typeof window !== 'undefined' && window.location?.origin
? window.location.origin
: 'https://accesslayer.app';

const plainUrl = `${origin}/creator/${creatorId}`;
const referralUrl = userAddress ? `${plainUrl}?ref=${userAddress}` : plainUrl;

const tweetText = buildShareTweetText(creatorName, priceXlm, referralUrl);
const intentUrl = buildTwitterIntentUrl(tweetText);

const handleShareClick = () => {
if (typeof window !== 'undefined') {
window.open(intentUrl, '_blank', 'noopener,noreferrer');
}
};

return (
<Button
type="button"
data-testid="share-twitter-button"
onClick={handleShareClick}
aria-label="Share key purchase to X"
className={`inline-flex items-center gap-2 rounded-xl bg-[#1DA1F2]/15 border border-[#1DA1F2]/30 px-4 py-2.5 text-sm font-semibold font-jakarta text-[#1DA1F2] transition-all hover:bg-[#1DA1F2]/25 hover:text-white ${className}`}
>
<Share2 className="size-4" aria-hidden="true" />
<span>Share to X</span>
</Button>
);
}

export default ShareTwitterButton;
Loading
Loading