Add initial plugin implementation and development setup

This commit is contained in:
2025-09-13 17:06:45 +02:00
parent 45fe248bf3
commit 74cef91401
52 changed files with 16645 additions and 0 deletions

13
dev/.env.example Normal file
View File

@@ -0,0 +1,13 @@
# Database URL - will use in-memory DB for development if not provided
DATABASE_URI=mongodb://127.0.0.1/payload-notifications-dev
# PayloadCMS Secret
PAYLOAD_SECRET=your-super-secret-jwt-secret
# VAPID Keys for Web Push Notifications
# Generate with: npx web-push generate-vapid-keys
VAPID_PUBLIC_KEY=BMrF5MbHcaEo6w4lPjG9m3BvONvFPfz7jLJ9t0F9yJGzSI3ZUHQj9fNUP7w2D8h1kI4x3YzJ1a4f0nS5g6t2F9L
VAPID_PRIVATE_KEY=your-vapid-private-key-here
# Development Settings
NODE_ENV=development

220
dev/README.md Normal file
View File

@@ -0,0 +1,220 @@
# Payload Notifications Plugin - Development Environment
This is the development environment for testing and demonstrating the `@xtr-dev/payload-notifications` plugin.
## 🚀 Quick Start
1. **Install dependencies:**
```bash
pnpm install
```
2. **Start the development server:**
```bash
pnpm dev
```
3. **Open the demo:**
- Homepage: [http://localhost:3000](http://localhost:3000)
- Admin Panel: [http://localhost:3000/admin](http://localhost:3000/admin)
- Push Demo: [http://localhost:3000/demo](http://localhost:3000/demo)
4. **Login to admin:**
- Email: `dev@payloadcms.com`
- Password: `test`
## 🔧 Configuration
The dev environment showcases a complete implementation of the notifications plugin with:
### Collections
- **Users** - Authentication with admin/customer roles
- **Products** - Sample e-commerce products
- **Orders** - Sample orders with different statuses
- **Posts** - Blog posts for content notifications
- **Notifications** - The plugin's notifications collection
- **Push Subscriptions** - Web push subscription management
### Plugin Configuration
```typescript
notificationsPlugin({
collections: {
slug: 'notifications',
labels: { singular: 'Notification', plural: 'Notifications' }
},
relationships: [
{ name: 'order', relationTo: 'orders', label: 'Related Order' },
{ name: 'product', relationTo: 'products', label: 'Related Product', hasMany: true },
{ name: 'post', relationTo: 'posts', label: 'Related Post' }
],
access: {
read: ({ req }) => Boolean(req.user),
create: ({ req }) => Boolean(req.user),
update: ({ req }) => Boolean(req.user),
delete: ({ req }) => Boolean(req.user?.role === 'admin'),
},
webPush: {
enabled: true,
vapidPublicKey: process.env.VAPID_PUBLIC_KEY,
vapidPrivateKey: process.env.VAPID_PRIVATE_KEY,
vapidSubject: 'mailto:test@example.com'
}
})
```
## 📱 Web Push Notifications
### Setup VAPID Keys
1. **Generate VAPID keys:**
```bash
npx web-push generate-vapid-keys
```
2. **Create a `.env` file:**
```bash
cp .env.example .env
```
3. **Add your VAPID keys to `.env`:**
```env
VAPID_PUBLIC_KEY=your-public-key
VAPID_PRIVATE_KEY=your-private-key
```
### API Endpoints
The plugin automatically creates these endpoints:
- `POST /api/push-notifications/subscribe` - Subscribe to push notifications
- `POST /api/push-notifications/unsubscribe` - Unsubscribe from push notifications
- `GET /api/push-notifications/vapid-public-key` - Get VAPID public key
- `POST /api/push-notifications/send` - Send notification to user
- `POST /api/push-notifications/test` - Send test notification (admin only)
- `POST /api/push-notifications/track` - Track notification events
### Service Worker Setup
The service worker is already configured and located at `/public/sw.js`. For new projects, generate it with:
```bash
npx @xtr-dev/payload-notifications generate-sw
```
The service worker handles:
- **Push message processing** - Receives and displays push notifications
- **Notification clicks** - Opens relevant admin panel or URLs
- **Test notifications** - Supports demo functionality
- **Analytics tracking** - Tracks notification interactions
### Testing Push Notifications
1. **Open the [demo page](http://localhost:3000/demo)**
2. **Enable notifications:**
- Click "Enable Notifications"
- Allow browser permissions when prompted
- The service worker will be registered automatically
3. **Test the system:**
- Click "Send Test Notification" to see instant notifications
- Check browser dev tools console for service worker logs
4. **Admin panel testing:**
- Go to `/admin` and create notifications
- Attach relationships to orders, products, or posts
- Real push notifications require proper VAPID keys
### Service Worker Features
- ✅ **Automatic registration** when subscribing to notifications
- ✅ **Test notification support** for immediate testing
- ✅ **Rich notification display** with actions and custom icons
- ✅ **Click handling** that opens relevant admin pages
- ✅ **Analytics tracking** for notification interactions
- ✅ **Fallback handling** for missing icons or data
## 📊 Sample Data
The development environment is automatically seeded with:
### Users
- **Admin User**: dev@payloadcms.com (password: test)
- **Customer User**: customer@example.com (password: test)
### Products
- Wireless Headphones ($299.99)
- Cotton T-Shirt ($24.99)
- JavaScript Guide ($39.99)
### Orders
- Order #ORD-001 (Shipped - Headphones + T-Shirt)
- Order #ORD-002 (Pending - JavaScript Guide)
### Notifications
- Welcome notification with blog post attachment
- Order shipped notification with order and product attachments
- Product recommendation notification (marked as read)
## 🛠️ Development
### File Structure
```
dev/
├── app/
│ ├── (app)/
│ │ ├── page.tsx # Homepage
│ │ └── demo/
│ │ └── page.tsx # Push notifications demo
│ └── (payload)/
│ ├── admin/ # Payload admin panel
│ └── api/ # API routes
├── helpers/
│ ├── credentials.ts # Default user credentials
│ └── testEmailAdapter.ts # Email testing
├── payload.config.ts # Payload configuration
├── seed.ts # Database seeding
└── .env.example # Environment variables template
```
### Environment Variables
- `DATABASE_URI` - MongoDB connection string (optional, uses in-memory DB)
- `PAYLOAD_SECRET` - JWT secret for authentication
- `VAPID_PUBLIC_KEY` - VAPID public key for web push
- `VAPID_PRIVATE_KEY` - VAPID private key for web push
- `NODE_ENV` - Environment (development/production)
### Scripts
- `pnpm dev` - Start development server
- `pnpm build` - Build the application
- `pnpm start` - Start production server
- `pnpm lint` - Run ESLint
- `pnpm test` - Run tests
## 🔍 Testing the Plugin
1. **Admin Panel Testing:**
- Create notifications with different relationship attachments
- Test read/unread functionality
- View push subscriptions
- Test user role permissions
2. **API Testing:**
- Test push notification endpoints
- Subscribe/unsubscribe from push notifications
- Send test notifications
3. **Client Integration:**
- Test the demo page functionality
- Test push notification permissions
- Test service worker integration
## 🚀 Production Deployment
1. Set up a real MongoDB database
2. Configure proper VAPID keys
3. Set up SSL certificates for push notifications
4. Configure proper environment variables
5. Deploy using your preferred platform
## 📚 Documentation
For complete documentation, see the main [README.md](../README.md) file.

493
dev/app/(app)/demo/page.tsx Normal file
View File

@@ -0,0 +1,493 @@
'use client'
import { useState, useEffect } from 'react'
// Enhanced demo implementation with real service worker registration
class DemoClientPushManager {
private vapidPublicKey: string
private serviceWorkerPath: string
private apiEndpoint: string
constructor(vapidPublicKey: string, options: { serviceWorkerPath?: string; apiEndpoint?: string } = {}) {
this.vapidPublicKey = vapidPublicKey
this.serviceWorkerPath = options.serviceWorkerPath || '/sw.js'
this.apiEndpoint = options.apiEndpoint || '/api/push-notifications'
}
public isSupported(): boolean {
if (typeof window === 'undefined') return false
return (
'serviceWorker' in navigator &&
'PushManager' in window &&
'Notification' in window
)
}
public getPermissionStatus(): NotificationPermission {
if (typeof window === 'undefined' || typeof Notification === 'undefined') return 'default'
return Notification.permission
}
public async requestPermission(): Promise<NotificationPermission> {
if (!this.isSupported()) {
throw new Error('Push notifications are not supported')
}
return await Notification.requestPermission()
}
public async registerServiceWorker(): Promise<ServiceWorkerRegistration> {
if (!this.isSupported()) {
throw new Error('Service workers are not supported')
}
try {
const registration = await navigator.serviceWorker.register(this.serviceWorkerPath)
console.log('Service worker registered:', registration)
// Wait for service worker to be ready
await navigator.serviceWorker.ready
return registration
} catch (error) {
console.error('Service worker registration failed:', error)
throw error
}
}
public async subscribe(): Promise<any> {
const permission = await this.requestPermission()
if (permission !== 'granted') {
throw new Error('Notification permission not granted')
}
const registration = await this.registerServiceWorker()
// For demo purposes, we'll simulate subscription without actual VAPID keys
// In production, you would use real VAPID keys here
try {
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this.urlBase64ToUint8Array(this.vapidPublicKey),
})
console.log('Push subscription:', subscription)
return {
endpoint: subscription.endpoint,
keys: {
p256dh: this.arrayBufferToBase64(subscription.getKey('p256dh')!),
auth: this.arrayBufferToBase64(subscription.getKey('auth')!),
},
}
} catch (error) {
console.warn('Real push subscription failed, simulating for demo:', error)
// Return simulated subscription for demo
return {
endpoint: 'demo-endpoint',
keys: { p256dh: 'demo-key', auth: 'demo-auth' }
}
}
}
public async isSubscribed(): Promise<boolean> {
if (!this.isSupported()) return false
try {
const registration = await navigator.serviceWorker.getRegistration()
if (!registration) return false
const subscription = await registration.pushManager.getSubscription()
return subscription !== null
} catch {
return false
}
}
public async unsubscribe(): Promise<void> {
try {
const registration = await navigator.serviceWorker.getRegistration()
if (!registration) return
const subscription = await registration.pushManager.getSubscription()
if (subscription) {
await subscription.unsubscribe()
}
} catch (error) {
console.error('Unsubscribe failed:', error)
}
}
public async sendTestNotification(): Promise<void> {
// Send a test notification using the service worker
const registration = await navigator.serviceWorker.getRegistration()
if (!registration) {
throw new Error('Service worker not registered')
}
// Simulate receiving a push message
if (registration.active) {
registration.active.postMessage({
type: 'TEST_NOTIFICATION',
payload: {
title: 'Test Notification',
body: 'This is a test notification from the demo!',
icon: '/icons/notification-icon.png',
badge: '/icons/notification-badge.png',
data: {
url: '/admin/collections/notifications',
notificationId: 'demo-' + Date.now()
}
}
})
}
// Also show a direct notification for testing
if (Notification.permission === 'granted') {
new Notification('Direct Test Notification', {
body: 'This notification was sent directly from JavaScript',
icon: '/icons/notification-icon.png',
tag: 'direct-test'
})
}
}
private urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}
private arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
const binary = Array.from(bytes).map(b => String.fromCharCode(b)).join('')
return window.btoa(binary)
}
}
// Available channels (should match the configuration in payload.config.ts)
const AVAILABLE_CHANNELS = [
{ id: 'general', name: 'General Notifications', description: 'General updates and announcements', defaultEnabled: true },
{ id: 'orders', name: 'Order Updates', description: 'Order status changes and shipping notifications', defaultEnabled: true },
{ id: 'products', name: 'Product Updates', description: 'New products, restocks, and price changes', defaultEnabled: false },
{ id: 'marketing', name: 'Marketing & Promotions', description: 'Special offers, sales, and promotional content', defaultEnabled: false },
]
export default function DemoPage() {
const [isSupported, setIsSupported] = useState(false)
const [isSubscribed, setIsSubscribed] = useState(false)
const [permission, setPermission] = useState<NotificationPermission>('default')
const [pushManager, setPushManager] = useState<DemoClientPushManager | null>(null)
const [loading, setLoading] = useState(false)
const [selectedChannels, setSelectedChannels] = useState<string[]>(
AVAILABLE_CHANNELS.filter(channel => channel.defaultEnabled).map(channel => channel.id)
)
useEffect(() => {
// Use the real VAPID public key from environment
const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || 'BNde-uFUkQB5BweFbOt_40Tn3xZahMop2JKT8kqRn4UqMMinieguHmVCTxwN_qfM-jZ0YFpVpIk3CWehlXcTl8A'
const manager = new DemoClientPushManager(vapidPublicKey)
setPushManager(manager)
setIsSupported(manager.isSupported())
setPermission(manager.getPermissionStatus())
if (manager.isSupported()) {
manager.isSubscribed().then(setIsSubscribed)
}
}, [])
const handleSubscribe = async () => {
if (!pushManager) return
setLoading(true)
try {
const subscription = await pushManager.subscribe()
// Save the subscription to Payload's database using the plugin's API endpoint
const response = await fetch('/api/push-notifications/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
subscription: subscription,
user: 'customer@example.com', // Associate with the demo customer user
userAgent: navigator.userAgent,
channels: selectedChannels,
}),
})
if (response.ok) {
setIsSubscribed(true)
setPermission('granted')
alert('Successfully subscribed to push notifications!\n\nSubscription saved to database.')
} else {
const error = await response.text()
throw new Error(`Failed to save subscription: ${error}`)
}
} catch (error) {
console.error('Failed to subscribe:', error)
alert('Failed to subscribe to push notifications: ' + (error as Error).message)
}
setLoading(false)
}
const handleUnsubscribe = async () => {
if (!pushManager) return
setLoading(true)
try {
await pushManager.unsubscribe()
// Remove the subscription from Payload's database
const response = await fetch('/api/push-notifications/unsubscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
user: 'customer@example.com', // Associate with the demo customer user
}),
})
if (response.ok) {
setIsSubscribed(false)
alert('Successfully unsubscribed from push notifications')
} else {
const error = await response.text()
console.warn('Failed to remove subscription from database:', error)
setIsSubscribed(false)
alert('Unsubscribed from browser, but may still be in database')
}
} catch (error) {
console.error('Failed to unsubscribe:', error)
alert('Failed to unsubscribe from push notifications: ' + (error as Error).message)
}
setLoading(false)
}
const handleTestNotification = async () => {
if (!pushManager) return
setLoading(true)
try {
await pushManager.sendTestNotification()
alert('Test notification sent! Check your browser notifications.')
} catch (error) {
console.error('Failed to send test notification:', error)
alert('Failed to send test notification: ' + (error as Error).message)
}
setLoading(false)
}
return (
<div style={{ padding: '2rem', maxWidth: '800px', margin: '0 auto' }}>
<h1>Payload Notifications Plugin Demo</h1>
<div style={{ marginBottom: '2rem', padding: '1rem', border: '1px solid #ccc', borderRadius: '8px' }}>
<h2>🔔 Web Push Notifications</h2>
{!isSupported ? (
<div style={{ color: 'red' }}>
Push notifications are not supported in this browser
</div>
) : (
<div>
<p><strong>Status:</strong> {isSubscribed ? '✅ Subscribed' : '❌ Not subscribed'}</p>
<p><strong>Permission:</strong> {permission}</p>
{!isSubscribed && (
<div style={{ marginTop: '1rem', marginBottom: '1rem' }}>
<h3 style={{ marginBottom: '0.5rem', fontSize: '1rem' }}>📢 Select Notification Channels</h3>
<p style={{ marginBottom: '1rem', fontSize: '0.9rem', color: '#666' }}>
Choose which types of notifications you want to receive:
</p>
<div style={{ display: 'grid', gap: '0.75rem' }}>
{AVAILABLE_CHANNELS.map(channel => (
<label
key={channel.id}
style={{
display: 'flex',
alignItems: 'flex-start',
gap: '0.5rem',
padding: '0.75rem',
border: '1px solid #e0e0e0',
borderRadius: '6px',
backgroundColor: selectedChannels.includes(channel.id) ? '#f0f9ff' : '#fafafa',
cursor: 'pointer'
}}
>
<input
type="checkbox"
checked={selectedChannels.includes(channel.id)}
onChange={(e) => {
if (e.target.checked) {
setSelectedChannels(prev => [...prev, channel.id])
} else {
setSelectedChannels(prev => prev.filter(id => id !== channel.id))
}
}}
style={{ marginTop: '0.2rem' }}
/>
<div>
<div style={{ fontWeight: '500', marginBottom: '0.25rem' }}>
{channel.name}
</div>
<div style={{ fontSize: '0.85rem', color: '#666' }}>
{channel.description}
</div>
</div>
</label>
))}
</div>
</div>
)}
<div style={{ marginTop: '1rem', display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
{!isSubscribed ? (
<button
onClick={handleSubscribe}
disabled={loading || selectedChannels.length === 0}
style={{
padding: '0.75rem 1.5rem',
backgroundColor: selectedChannels.length === 0 ? '#ccc' : '#007FFF',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: loading || selectedChannels.length === 0 ? 'not-allowed' : 'pointer',
opacity: loading || selectedChannels.length === 0 ? 0.6 : 1
}}
>
{loading ? 'Subscribing...' : selectedChannels.length === 0 ? 'Select at least one channel' : 'Enable Notifications'}
</button>
) : (
<>
<button
onClick={handleUnsubscribe}
disabled={loading}
style={{
padding: '0.75rem 1.5rem',
backgroundColor: '#dc3545',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: loading ? 'not-allowed' : 'pointer',
opacity: loading ? 0.6 : 1
}}
>
{loading ? 'Unsubscribing...' : 'Disable Notifications'}
</button>
<button
onClick={handleTestNotification}
disabled={loading}
style={{
padding: '0.75rem 1.5rem',
backgroundColor: '#28a745',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: loading ? 'not-allowed' : 'pointer',
opacity: loading ? 0.6 : 1
}}
>
{loading ? 'Sending...' : 'Send Test Notification'}
</button>
</>
)}
</div>
</div>
)}
</div>
<div style={{ marginBottom: '2rem', padding: '1rem', border: '1px solid #ccc', borderRadius: '8px' }}>
<h2>📱 Admin Panel Features</h2>
<p>The notifications plugin adds the following to your Payload admin panel:</p>
<ul>
<li><strong>Notifications Collection:</strong> Create and manage notifications with rich text content</li>
<li><strong>Push Subscriptions Collection:</strong> View and manage user push notification subscriptions (check here after subscribing!)</li>
<li><strong>📢 Channel-Based Subscriptions:</strong> Users can subscribe to specific notification channels (General, Orders, Products, Marketing)</li>
<li><strong>Read/Unread Tracking:</strong> Monitor which notifications have been read</li>
<li><strong>User Targeting:</strong> Send notifications to specific users</li>
<li><strong>🎯 Automatic Push Notifications:</strong> Push notifications are sent automatically when notifications are created!</li>
</ul>
</div>
<div style={{ marginBottom: '2rem', padding: '1rem', border: '2px solid #28a745', borderRadius: '8px', backgroundColor: '#f8fff8' }}>
<h2 style={{ color: '#28a745', marginBottom: '1rem' }}>🚀 Try Automatic Push Notifications</h2>
<div style={{ padding: '0.75rem', backgroundColor: '#fff3cd', borderRadius: '4px', marginBottom: '1rem', border: '1px solid #ffeaa7' }}>
<strong> Important:</strong> You must be signed in to subscribe to push notifications. The subscription associates with your user account.
</div>
<p style={{ marginBottom: '1rem' }}>
<strong>Step 1:</strong> <a href="/admin/login" target="_blank" style={{ color: '#007FFF' }}>Sign in to the admin panel</a> first (dev@payloadcms.com / test)
</p>
<p style={{ marginBottom: '1rem' }}>
<strong>Step 2:</strong> Return here and subscribe to push notifications above
</p>
<p style={{ marginBottom: '1rem' }}>
<strong>Step 3:</strong> Go to the <a href="/admin" target="_blank" style={{ color: '#007FFF' }}>admin panel</a> and create a new notification
</p>
<p style={{ marginBottom: '1rem' }}>
<strong>Step 4:</strong> Set the recipient to "customer@example.com" (the test user)
</p>
<p style={{ marginBottom: '1rem' }}>
<strong>Step 5:</strong> Choose a notification channel (General, Orders, Products, or Marketing) - must match your subscription
</p>
<p style={{ marginBottom: '1rem' }}>
<strong>Step 6:</strong> Save the notification and watch for an automatic push notification! 🎉
</p>
<div style={{ padding: '0.75rem', backgroundColor: '#e7f3ff', borderRadius: '4px', fontSize: '0.9rem' }}>
<strong>💡 How it works:</strong> When you create a notification in the admin panel, the plugin automatically:
<ul style={{ margin: '0.5rem 0', paddingLeft: '1.5rem' }}>
<li>Extracts the title and message content</li>
<li>Finds all push subscriptions for the recipient</li>
<li>Sends push notifications to their devices</li>
<li>Handles errors gracefully without breaking the notification creation</li>
</ul>
</div>
</div>
<div style={{ marginBottom: '2rem', padding: '1rem', border: '1px solid #ccc', borderRadius: '8px' }}>
<h2>🚀 API Endpoints</h2>
<p>The plugin automatically creates these API endpoints for web push:</p>
<ul>
<li><code>POST /api/push-notifications/subscribe</code> - Subscribe to push notifications</li>
<li><code>POST /api/push-notifications/unsubscribe</code> - Unsubscribe from push notifications</li>
<li><code>GET /api/push-notifications/vapid-public-key</code> - Get VAPID public key</li>
<li><code>POST /api/push-notifications/send</code> - Send notification to user</li>
<li><code>POST /api/push-notifications/test</code> - Send test notification (admin only)</li>
</ul>
</div>
<div style={{ marginBottom: '2rem', padding: '1rem', border: '1px solid #ccc', borderRadius: '8px' }}>
<h2>💡 Getting Started</h2>
<ol>
<li>Generate VAPID keys: <code>npx web-push generate-vapid-keys</code></li>
<li>Add the keys to your <code>.env</code> file</li>
<li>Create a service worker at <code>/public/sw.js</code></li>
<li>Use the client-side utilities to manage subscriptions</li>
<li>Send notifications programmatically or via the admin panel</li>
</ol>
</div>
<div style={{ padding: '1rem', backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
<h3>📋 Sample Data</h3>
<p>This demo includes:</p>
<ul>
<li>Sample users (admin and customer)</li>
<li>Sample notifications demonstrating channels</li>
<li>Push subscription management with channel filtering</li>
<li>Automatic push notification hooks</li>
</ul>
<p>
<strong>Login:</strong> dev@payloadcms.com / test<br/>
<strong>Admin Panel:</strong> <a href="/admin" target="_blank">/admin</a>
</p>
</div>
</div>
)
}

92
dev/app/(app)/layout.tsx Normal file
View File

@@ -0,0 +1,92 @@
export default function AppLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body style={{ margin: 0, fontFamily: 'system-ui, sans-serif' }}>
<div style={{ minHeight: '100vh', backgroundColor: '#fafafa' }}>
<nav style={{
backgroundColor: 'white',
borderBottom: '1px solid #e5e7eb',
padding: '1rem 2rem',
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1)'
}}>
<div style={{
maxWidth: '1200px',
margin: '0 auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontSize: '1.5rem' }}>🔔</span>
<span style={{ fontWeight: 'bold', fontSize: '1.1rem' }}>
Payload Notifications
</span>
</div>
<div style={{ display: 'flex', gap: '1rem' }}>
<a
href="/"
style={{
color: '#007FFF',
textDecoration: 'none',
padding: '0.5rem 1rem',
borderRadius: '4px'
}}
>
Home
</a>
<a
href="/demo"
style={{
color: '#007FFF',
textDecoration: 'none',
padding: '0.5rem 1rem',
borderRadius: '4px'
}}
>
Push Demo
</a>
<a
href="/admin"
style={{
color: '#007FFF',
textDecoration: 'none',
padding: '0.5rem 1rem',
borderRadius: '4px',
border: '1px solid #007FFF'
}}
>
Admin Panel
</a>
</div>
</div>
</nav>
<main>
{children}
</main>
<footer style={{
marginTop: '4rem',
padding: '2rem',
textAlign: 'center',
borderTop: '1px solid #e5e7eb',
backgroundColor: 'white',
color: '#6b7280'
}}>
<p>
🔔 Payload Notifications Plugin Demo |
<a
href="https://github.com/xtr-dev/payload-notifications"
style={{ color: '#007FFF', marginLeft: '0.5rem' }}
>
Documentation
</a>
</p>
</footer>
</div>
</body>
</html>
)
}

126
dev/app/(app)/page.tsx Normal file
View File

@@ -0,0 +1,126 @@
import Link from 'next/link'
export default function HomePage() {
return (
<div style={{ padding: '2rem', maxWidth: '800px', margin: '0 auto' }}>
<div style={{ textAlign: 'center', marginBottom: '3rem' }}>
<h1 style={{ fontSize: '2.5rem', marginBottom: '1rem' }}>
🔔 Payload Notifications Plugin
</h1>
<p style={{ fontSize: '1.2rem', color: '#666' }}>
A comprehensive demo of the @xtr-dev/payload-notifications plugin
</p>
</div>
<div style={{ display: 'grid', gap: '1.5rem', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))' }}>
<div style={{
padding: '2rem',
border: '2px solid #007FFF',
borderRadius: '12px',
backgroundColor: '#f8f9ff'
}}>
<h2 style={{ marginBottom: '1rem', color: '#007FFF' }}>🛠 Admin Panel</h2>
<p style={{ marginBottom: '1.5rem' }}>
Access the Payload admin panel to manage notifications, users, orders, and more.
</p>
<Link
href="/admin"
style={{
display: 'inline-block',
padding: '0.75rem 1.5rem',
backgroundColor: '#007FFF',
color: 'white',
textDecoration: 'none',
borderRadius: '6px',
fontWeight: '500'
}}
>
Open Admin Panel
</Link>
<div style={{ marginTop: '1rem', fontSize: '0.9rem', color: '#666' }}>
<strong>Login:</strong> dev@payloadcms.com / test
</div>
</div>
<div style={{
padding: '2rem',
border: '2px solid #28a745',
borderRadius: '12px',
backgroundColor: '#f8fff8'
}}>
<h2 style={{ marginBottom: '1rem', color: '#28a745' }}>📱 Push Notifications Demo</h2>
<p style={{ marginBottom: '1.5rem' }}>
Test the web push notification features and see how they work in a real application.
</p>
<Link
href="/demo"
style={{
display: 'inline-block',
padding: '0.75rem 1.5rem',
backgroundColor: '#28a745',
color: 'white',
textDecoration: 'none',
borderRadius: '6px',
fontWeight: '500'
}}
>
View Demo
</Link>
</div>
</div>
<div style={{ marginTop: '3rem', padding: '2rem', backgroundColor: '#f8f9fa', borderRadius: '12px' }}>
<h2 style={{ marginBottom: '1.5rem' }}>🚀 What&apos;s Included</h2>
<div style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))' }}>
<div>
<h3 style={{ color: '#007FFF', marginBottom: '0.5rem' }}>📧 Notifications Collection</h3>
<p style={{ fontSize: '0.9rem', color: '#666' }}>
Rich text notifications with read/unread tracking and recipient targeting
</p>
</div>
<div>
<h3 style={{ color: '#007FFF', marginBottom: '0.5rem' }}>🔗 Relationship Attachments</h3>
<p style={{ fontSize: '0.9rem', color: '#666' }}>
Link notifications to orders, products, posts, or any collection
</p>
</div>
<div>
<h3 style={{ color: '#007FFF', marginBottom: '0.5rem' }}>🔔 Web Push Support</h3>
<p style={{ fontSize: '0.9rem', color: '#666' }}>
VAPID-secured push notifications for mobile and desktop browsers
</p>
</div>
<div>
<h3 style={{ color: '#007FFF', marginBottom: '0.5rem' }}> Configurable Access</h3>
<p style={{ fontSize: '0.9rem', color: '#666' }}>
Flexible access control with role-based permissions
</p>
</div>
</div>
</div>
<div style={{ marginTop: '2rem', padding: '1.5rem', border: '1px solid #dee2e6', borderRadius: '8px' }}>
<h3 style={{ marginBottom: '1rem' }}>📋 Sample Data</h3>
<p style={{ marginBottom: '1rem' }}>This demo environment includes:</p>
<ul style={{ marginLeft: '1.5rem', color: '#666' }}>
<li>Admin user (dev@payloadcms.com) and customer user</li>
<li>Sample products (headphones, t-shirt, JavaScript guide)</li>
<li>Sample orders with different statuses</li>
<li>Sample notifications with relationship attachments</li>
<li>Push subscription management</li>
</ul>
</div>
<div style={{ marginTop: '2rem', textAlign: 'center', color: '#666' }}>
<p>
📖 <a href="https://github.com/xtr-dev/payload-notifications" style={{ color: '#007FFF' }}>
View Documentation
</a> |
🐙 <a href="https://github.com/xtr-dev/payload-notifications" style={{ color: '#007FFF' }}>
GitHub Repository
</a>
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,25 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from 'next'
import config from '@payload-config'
import { generatePageMetadata, NotFoundPage } from '@payloadcms/next/views'
import { importMap } from '../importMap.js'
type Args = {
params: Promise<{
segments: string[]
}>
searchParams: Promise<{
[key: string]: string | string[]
}>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const NotFound = ({ params, searchParams }: Args) =>
NotFoundPage({ config, importMap, params, searchParams })
export default NotFound

View File

@@ -0,0 +1,25 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from 'next'
import config from '@payload-config'
import { generatePageMetadata, RootPage } from '@payloadcms/next/views'
import { importMap } from '../importMap.js'
type Args = {
params: Promise<{
segments: string[]
}>
searchParams: Promise<{
[key: string]: string | string[]
}>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const Page = ({ params, searchParams }: Args) =>
RootPage({ config, importMap, params, searchParams })
export default Page

View File

@@ -0,0 +1,49 @@
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
export const importMap = {
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864
}

View File

@@ -0,0 +1,19 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import '@payloadcms/next/css'
import {
REST_DELETE,
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
REST_PUT,
} from '@payloadcms/next/routes'
export const GET = REST_GET(config)
export const POST = REST_POST(config)
export const DELETE = REST_DELETE(config)
export const PATCH = REST_PATCH(config)
export const PUT = REST_PUT(config)
export const OPTIONS = REST_OPTIONS(config)

View File

@@ -0,0 +1,7 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import '@payloadcms/next/css'
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes'
export const GET = GRAPHQL_PLAYGROUND_GET(config)

View File

@@ -0,0 +1,8 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes'
export const POST = GRAPHQL_POST(config)
export const OPTIONS = REST_OPTIONS(config)

View File

View File

@@ -0,0 +1,32 @@
import type { ServerFunctionClient } from 'payload'
import '@payloadcms/next/css'
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts'
import React from 'react'
import { importMap } from './admin/importMap.js'
import './custom.scss'
type Args = {
children: React.ReactNode
}
const serverFunction: ServerFunctionClient = async function (args) {
'use server'
return handleServerFunctions({
...args,
config,
importMap,
})
}
const Layout = ({ children }: Args) => (
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
{children}
</RootLayout>
)
export default Layout

14
dev/app/layout.tsx Normal file
View File

@@ -0,0 +1,14 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Payload Notifications Plugin Demo',
description: 'Demo environment for the @xtr-dev/payload-notifications plugin',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}

12
dev/app/my-route/route.ts Normal file
View File

@@ -0,0 +1,12 @@
import configPromise from '@payload-config'
import { getPayload } from 'payload'
export const GET = async (request: Request) => {
const payload = await getPayload({
config: configPromise,
})
return Response.json({
message: 'This is an example of a custom route.',
})
}

15
dev/e2e.spec.ts Normal file
View File

@@ -0,0 +1,15 @@
import { expect, test } from '@playwright/test'
// this is an example Playwright e2e test
test('should render admin panel logo', async ({ page }) => {
await page.goto('/admin')
// login
await page.fill('#field-email', 'dev@payloadcms.com')
await page.fill('#field-password', 'test')
await page.click('.form-submit button')
// should show dashboard
await expect(page).toHaveTitle(/Dashboard/)
await expect(page.locator('.graphic-icon')).toBeVisible()
})

View File

@@ -0,0 +1,4 @@
export const devUser = {
email: 'dev@payloadcms.com',
password: 'test',
}

View File

@@ -0,0 +1,38 @@
import type { EmailAdapter, SendEmailOptions } from 'payload'
/**
* Logs all emails to stdout
*/
export const testEmailAdapter: EmailAdapter<void> = ({ payload }) => ({
name: 'test-email-adapter',
defaultFromAddress: 'dev@payloadcms.com',
defaultFromName: 'Payload Test',
sendEmail: async (message) => {
const stringifiedTo = getStringifiedToAddress(message)
const res = `Test email to: '${stringifiedTo}', Subject: '${message.subject}'`
payload.logger.info({ content: message, msg: res })
return Promise.resolve()
},
})
function getStringifiedToAddress(message: SendEmailOptions): string | undefined {
let stringifiedTo: string | undefined
if (typeof message.to === 'string') {
stringifiedTo = message.to
} else if (Array.isArray(message.to)) {
stringifiedTo = message.to
.map((to: { address: string } | string) => {
if (typeof to === 'string') {
return to
} else if (to.address) {
return to.address
}
return ''
})
.join(', ')
} else if (message.to?.address) {
stringifiedTo = message.to.address
}
return stringifiedTo
}

52
dev/int.spec.ts Normal file
View File

@@ -0,0 +1,52 @@
import type { Payload } from 'payload'
import config from '@payload-config'
import { createPayloadRequest, getPayload } from 'payload'
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
import { customEndpointHandler } from '../src/endpoints/customEndpointHandler.js'
let payload: Payload
afterAll(async () => {
await payload.destroy()
})
beforeAll(async () => {
payload = await getPayload({ config })
})
describe('Plugin integration tests', () => {
test('should query custom endpoint added by plugin', async () => {
const request = new Request('http://localhost:3000/api/my-plugin-endpoint', {
method: 'GET',
})
const payloadRequest = await createPayloadRequest({ config, request })
const response = await customEndpointHandler(payloadRequest)
expect(response.status).toBe(200)
const data = await response.json()
expect(data).toMatchObject({
message: 'Hello from custom endpoint',
})
})
test('can create post with custom text field added by plugin', async () => {
const post = await payload.create({
collection: 'posts',
data: {
addedByPlugin: 'added by plugin',
},
})
expect(post.addedByPlugin).toBe('added by plugin')
})
test('plugin creates and seeds plugin-collection', async () => {
expect(payload.collections['plugin-collection']).toBeDefined()
const { docs } = await payload.find({ collection: 'plugin-collection' })
expect(docs).toHaveLength(1)
})
})

5
dev/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

21
dev/next.config.mjs Normal file
View File

@@ -0,0 +1,21 @@
import { withPayload } from '@payloadcms/next/withPayload'
import { fileURLToPath } from 'url'
import path from 'path'
const dirname = path.dirname(fileURLToPath(import.meta.url))
/** @type {import('next').NextConfig} */
const nextConfig = {
webpack: (webpackConfig) => {
webpackConfig.resolve.extensionAlias = {
'.cjs': ['.cts', '.cjs'],
'.js': ['.ts', '.tsx', '.js', '.jsx'],
'.mjs': ['.mts', '.mjs'],
}
return webpackConfig
},
serverExternalPackages: ['mongodb-memory-server'],
}
export default withPayload(nextConfig, { devBundleServerPackages: false })

531
dev/payload-types.ts Normal file
View File

@@ -0,0 +1,531 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
/**
* Supported timezones in IANA format.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supportedTimezones".
*/
export type SupportedTimezones =
| 'Pacific/Midway'
| 'Pacific/Niue'
| 'Pacific/Honolulu'
| 'Pacific/Rarotonga'
| 'America/Anchorage'
| 'Pacific/Gambier'
| 'America/Los_Angeles'
| 'America/Tijuana'
| 'America/Denver'
| 'America/Phoenix'
| 'America/Chicago'
| 'America/Guatemala'
| 'America/New_York'
| 'America/Bogota'
| 'America/Caracas'
| 'America/Santiago'
| 'America/Buenos_Aires'
| 'America/Sao_Paulo'
| 'Atlantic/South_Georgia'
| 'Atlantic/Azores'
| 'Atlantic/Cape_Verde'
| 'Europe/London'
| 'Europe/Berlin'
| 'Africa/Lagos'
| 'Europe/Athens'
| 'Africa/Cairo'
| 'Europe/Moscow'
| 'Asia/Riyadh'
| 'Asia/Dubai'
| 'Asia/Baku'
| 'Asia/Karachi'
| 'Asia/Tashkent'
| 'Asia/Calcutta'
| 'Asia/Dhaka'
| 'Asia/Almaty'
| 'Asia/Jakarta'
| 'Asia/Bangkok'
| 'Asia/Shanghai'
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
| 'Pacific/Auckland'
| 'Pacific/Fiji';
export interface Config {
auth: {
users: UserAuthOperations;
};
blocks: {};
collections: {
users: User;
orders: Order;
products: Product;
posts: Post;
media: Media;
notifications: Notification;
'push-subscriptions': PushSubscription;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
collectionsJoins: {};
collectionsSelect: {
users: UsersSelect<false> | UsersSelect<true>;
orders: OrdersSelect<false> | OrdersSelect<true>;
products: ProductsSelect<false> | ProductsSelect<true>;
posts: PostsSelect<false> | PostsSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
notifications: NotificationsSelect<false> | NotificationsSelect<true>;
'push-subscriptions': PushSubscriptionsSelect<false> | PushSubscriptionsSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: string;
};
globals: {};
globalsSelect: {};
locale: null;
user: User & {
collection: 'users';
};
jobs: {
tasks: unknown;
workflows: unknown;
};
}
export interface UserAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: string;
role: 'admin' | 'customer';
firstName?: string | null;
lastName?: string | null;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
password?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "orders".
*/
export interface Order {
id: string;
orderNumber: string;
customer: string | User;
status?: ('pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled') | null;
total: number;
products?: (string | Product)[] | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "products".
*/
export interface Product {
id: string;
name: string;
description?: string | null;
price: number;
category?: ('electronics' | 'clothing' | 'books' | 'home-garden') | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts".
*/
export interface Post {
id: string;
title: string;
content?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
author?: (string | null) | User;
publishedAt?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
*/
export interface Media {
id: string;
alt?: string | null;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/**
* Manage user notifications and messaging
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "notifications".
*/
export interface Notification {
id: string;
/**
* The notification title that will be displayed to users
*/
title: string;
/**
* The notification message content
*/
message: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
};
/**
* The user who should receive this notification
*/
recipient: string | User;
/**
* Whether this notification has been read by the recipient
*/
isRead?: boolean | null;
/**
* When this notification was marked as read
*/
readAt?: string | null;
attachments?: {
order?: (string | null) | Order;
product?: (string | Product)[] | null;
post?: (string | null) | Post;
};
updatedAt: string;
createdAt: string;
}
/**
* Web push notification subscriptions for users
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "push-subscriptions".
*/
export interface PushSubscription {
id: string;
/**
* The user this push subscription belongs to
*/
user: string | User;
/**
* Push service endpoint URL
*/
endpoint: string;
/**
* User agent public key for encryption
*/
p256dh: string;
/**
* User agent authentication secret
*/
auth: string;
/**
* Browser/device information
*/
userAgent?: string | null;
/**
* Whether this subscription is still active
*/
isActive?: boolean | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: string;
document?:
| ({
relationTo: 'users';
value: string | User;
} | null)
| ({
relationTo: 'orders';
value: string | Order;
} | null)
| ({
relationTo: 'products';
value: string | Product;
} | null)
| ({
relationTo: 'posts';
value: string | Post;
} | null)
| ({
relationTo: 'media';
value: string | Media;
} | null)
| ({
relationTo: 'notifications';
value: string | Notification;
} | null)
| ({
relationTo: 'push-subscriptions';
value: string | PushSubscription;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: string | User;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: string;
user: {
relationTo: 'users';
value: string | User;
};
key?: string | null;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: string;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".
*/
export interface UsersSelect<T extends boolean = true> {
role?: T;
firstName?: T;
lastName?: T;
updatedAt?: T;
createdAt?: T;
email?: T;
resetPasswordToken?: T;
resetPasswordExpiration?: T;
salt?: T;
hash?: T;
loginAttempts?: T;
lockUntil?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "orders_select".
*/
export interface OrdersSelect<T extends boolean = true> {
orderNumber?: T;
customer?: T;
status?: T;
total?: T;
products?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "products_select".
*/
export interface ProductsSelect<T extends boolean = true> {
name?: T;
description?: T;
price?: T;
category?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts_select".
*/
export interface PostsSelect<T extends boolean = true> {
title?: T;
content?: T;
author?: T;
publishedAt?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media_select".
*/
export interface MediaSelect<T extends boolean = true> {
alt?: T;
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "notifications_select".
*/
export interface NotificationsSelect<T extends boolean = true> {
title?: T;
message?: T;
recipient?: T;
isRead?: T;
readAt?: T;
attachments?:
| T
| {
order?: T;
product?: T;
post?: T;
};
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "push-subscriptions_select".
*/
export interface PushSubscriptionsSelect<T extends boolean = true> {
user?: T;
endpoint?: T;
p256dh?: T;
auth?: T;
userAgent?: T;
isActive?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".
*/
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
document?: T;
globalSlug?: T;
user?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences_select".
*/
export interface PayloadPreferencesSelect<T extends boolean = true> {
user?: T;
key?: T;
value?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations_select".
*/
export interface PayloadMigrationsSelect<T extends boolean = true> {
name?: T;
batch?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
*/
export interface Auth {
[k: string]: unknown;
}
declare module 'payload' {
export interface GeneratedTypes extends Config {}
}

176
dev/payload.config.ts Normal file
View File

@@ -0,0 +1,176 @@
import { mongooseAdapter } from '@payloadcms/db-mongodb'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import path from 'path'
import { buildConfig } from 'payload'
import sharp from 'sharp'
import { fileURLToPath } from 'url'
import { testEmailAdapter } from './helpers/testEmailAdapter.ts'
import { seed } from './seed.ts'
import { notificationsPlugin } from '@xtr-dev/payload-notifications'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
if (!process.env.ROOT_DIR) {
process.env.ROOT_DIR = dirname
}
const buildConfigWithMemoryDB = async () => {
if (!process.env.DATABASE_URI) {
// Use a simple memory server instead of replica set for better stability
const { MongoMemoryServer } = await import('mongodb-memory-server')
const memoryDB = await MongoMemoryServer.create({
instance: {
dbName: 'payloadmemory',
},
})
process.env.DATABASE_URI = memoryDB.getUri()
}
return buildConfig({
admin: {
importMap: {
baseDir: path.resolve(dirname),
},
},
collections: [
// Users collection with roles for authentication
{
slug: 'users',
auth: true,
admin: {
useAsTitle: 'email',
},
fields: [
{
name: 'role',
type: 'select',
options: [
{ label: 'Admin', value: 'admin' },
{ label: 'Customer', value: 'customer' },
],
defaultValue: 'customer',
required: true,
},
{
name: 'firstName',
type: 'text',
label: 'First Name',
},
{
name: 'lastName',
type: 'text',
label: 'Last Name',
},
],
},
],
db: mongooseAdapter({
ensureIndexes: true,
url: process.env.DATABASE_URI || '',
}),
editor: lexicalEditor(),
email: testEmailAdapter,
onInit: async (payload) => {
await seed(payload)
},
plugins: [
// Demo of the notifications plugin with relationships and channels
notificationsPlugin({
collections: {
slug: 'notifications',
labels: {
singular: 'Notification',
plural: 'Notifications'
}
},
channels: [
{
id: 'general',
name: 'General Notifications',
description: 'General updates and announcements',
defaultEnabled: true
},
{
id: 'orders',
name: 'Order Updates',
description: 'Order status changes and shipping notifications',
defaultEnabled: true
},
{
id: 'products',
name: 'Product Updates',
description: 'New products, restocks, and price changes',
defaultEnabled: false
},
{
id: 'marketing',
name: 'Marketing & Promotions',
description: 'Special offers, sales, and promotional content',
defaultEnabled: false
}
],
access: {
read: ({ req }: { req: any }) => Boolean(req.user),
create: ({ req }: { req: any }) => Boolean(req.user),
update: ({ req }: { req: any }) => Boolean(req.user),
delete: ({ req }: { req: any }) => Boolean(req.user?.role === 'admin'),
},
webPush: {
enabled: true,
autoPush: true, // Enable automatic push notifications
vapidPublicKey: process.env.VAPID_PUBLIC_KEY || 'BMrF5MbHcaEo6w4lPjG9m3BvONvFPfz7jLJ9t0F9yJGzSI3ZUHQj9fNUP7w2D8h1kI4x3YzJ1a4f0nS5g6t2F9L',
vapidPrivateKey: process.env.VAPID_PRIVATE_KEY || 'your-private-key-here',
vapidSubject: 'mailto:test@example.com',
// Custom notification transformer for demo
transformNotification: (notification: any) => {
const title = notification.title || 'New Notification'
// Extract text from rich text message
let body = 'You have a new notification'
if (notification.message && Array.isArray(notification.message)) {
const textParts: string[] = []
notification.message.forEach((block: any) => {
if (block.children && Array.isArray(block.children)) {
block.children.forEach((child: any) => {
if (child.text) textParts.push(child.text)
})
}
})
if (textParts.length > 0) {
body = textParts.join(' ').substring(0, 120) + (textParts.join(' ').length > 120 ? '...' : '')
}
}
return {
title: `🔔 ${title}`,
body,
icon: '/icons/notification-icon.png',
badge: '/icons/notification-badge.png',
data: {
notificationId: notification.id,
url: `/admin/collections/notifications/${notification.id}`,
createdAt: notification.createdAt,
},
actions: [
{ action: 'view', title: 'View in Admin', icon: '/icons/view.png' },
{ action: 'dismiss', title: 'Dismiss', icon: '/icons/dismiss.png' }
],
tag: `notification-${notification.id}`,
requireInteraction: false,
}
}
}
}),
],
secret: process.env.PAYLOAD_SECRET || 'test-secret_key',
sharp,
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})
}
export default buildConfigWithMemoryDB()

View File

@@ -0,0 +1,27 @@
# Notification Icons
This directory contains icons for web push notifications.
## Required Icons:
- `notification-icon.png` - Main notification icon (recommended: 192x192px)
- `notification-badge.png` - Small badge icon (recommended: 72x72px)
- `view.png` - View action icon (recommended: 32x32px)
- `dismiss.png` - Dismiss action icon (recommended: 32x32px)
## Icon Requirements:
1. **Format**: PNG with transparency support
2. **Size**: Multiple sizes recommended (72x72, 96x96, 128x128, 192x192, 256x256, 512x512)
3. **Design**: Simple, clear, recognizable at small sizes
4. **Background**: Transparent or solid color that works on any background
## Fallback:
If custom icons are not provided, the service worker will use these default paths:
- `/icons/notification-icon.png`
- `/icons/notification-badge.png`
- `/icons/view.png`
- `/icons/dismiss.png`
You can create simple colored PNG files or use emoji-based icons for testing.

197
dev/public/sw.js Normal file
View File

@@ -0,0 +1,197 @@
/**
* Service Worker for Web Push Notifications
* Payload Notifications Plugin Demo
*/
console.log('[SW] Service worker loaded')
// Service worker lifecycle events
self.addEventListener('install', (event) => {
console.log('[SW] Installing service worker')
self.skipWaiting()
})
self.addEventListener('activate', (event) => {
console.log('[SW] Activating service worker')
event.waitUntil(self.clients.claim())
})
// Handle push events
self.addEventListener('push', (event) => {
console.log('[SW] Push event received')
if (!event.data) {
console.log('[SW] Push event has no data')
return
}
try {
const payload = event.data.json()
console.log('[SW] Push payload:', payload)
const { title, body, icon, badge, image, data, actions, tag, requireInteraction } = payload
const notificationOptions = {
body,
icon: icon || '/icons/notification-icon.png',
badge: badge || '/icons/notification-badge.png',
image,
data,
actions: actions || [
{ action: 'view', title: 'View', icon: '/icons/view.png' },
{ action: 'dismiss', title: 'Dismiss', icon: '/icons/dismiss.png' }
],
tag: tag || 'notification',
requireInteraction: requireInteraction || false,
timestamp: Date.now(),
vibrate: [200, 100, 200],
renotify: true,
}
event.waitUntil(
self.registration.showNotification(title || 'New Notification', notificationOptions)
)
} catch (error) {
console.error('[SW] Error processing push notification:', error)
// Fallback notification
event.waitUntil(
self.registration.showNotification('New Notification', {
body: 'You have a new notification',
icon: '/icons/notification-icon.png',
badge: '/icons/notification-badge.png',
tag: 'fallback',
})
)
}
})
// Handle notification clicks
self.addEventListener('notificationclick', (event) => {
console.log('[SW] Notification click received')
console.log('[SW] Action:', event.action)
console.log('[SW] Notification data:', event.notification.data)
event.notification.close()
const data = event.notification.data || {}
// Handle action button clicks
if (event.action) {
switch (event.action) {
case 'view':
if (data.url) {
event.waitUntil(
clients.openWindow(data.url)
)
} else {
event.waitUntil(
clients.openWindow('/admin/collections/notifications')
)
}
break
case 'dismiss':
// Just close the notification (already done above)
break
default:
console.log('[SW] Unknown action:', event.action)
}
} else {
// Default click behavior - open the admin panel or specific URL
const urlToOpen = data.url || '/admin/collections/notifications'
event.waitUntil(
clients.matchAll({ type: 'window' }).then((windowClients) => {
// Check if there is already an open window
for (const client of windowClients) {
if (client.url.includes('/admin') && 'focus' in client) {
return client.focus()
}
}
// If no admin window is open, open a new one
return clients.openWindow(urlToOpen)
})
)
}
// Track notification click
if (data.notificationId) {
fetch('/api/push-notifications/track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'click',
notificationId: data.notificationId,
timestamp: Date.now(),
}),
}).catch((error) => {
console.error('[SW] Failed to track notification click:', error)
})
}
})
// Handle notification close events
self.addEventListener('notificationclose', (event) => {
console.log('[SW] Notification closed:', event.notification.tag)
const data = event.notification.data || {}
// Track notification close
if (data.notificationId) {
fetch('/api/push-notifications/track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'close',
notificationId: data.notificationId,
timestamp: Date.now(),
}),
}).catch((error) => {
console.error('[SW] Failed to track notification close:', error)
})
}
})
// Handle background sync (optional)
self.addEventListener('sync', (event) => {
console.log('[SW] Background sync:', event.tag)
if (event.tag === 'push-notification-sync') {
event.waitUntil(
// Handle offline notification sync
Promise.resolve()
)
}
})
// Handle message events from the main thread
self.addEventListener('message', (event) => {
console.log('[SW] Message received:', event.data)
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting()
}
// Handle test notifications sent from the demo page
if (event.data && event.data.type === 'TEST_NOTIFICATION') {
const payload = event.data.payload
self.registration.showNotification(payload.title, {
body: payload.body,
icon: payload.icon,
badge: payload.badge,
data: payload.data,
actions: [
{ action: 'view', title: 'View', icon: '/icons/view.png' },
{ action: 'dismiss', title: 'Dismiss', icon: '/icons/dismiss.png' }
],
tag: 'test-notification',
requireInteraction: false,
timestamp: Date.now(),
vibrate: [200, 100, 200],
})
}
})
console.log('[SW] Service worker setup complete')

183
dev/seed.ts Normal file
View File

@@ -0,0 +1,183 @@
import type { Payload } from 'payload'
import { devUser } from './helpers/credentials.ts'
export const seed = async (payload: Payload) => {
console.log('Seeding database...')
// Check if admin user exists
const { totalDocs } = await payload.count({
collection: 'users',
where: {
email: {
equals: devUser.email,
},
},
})
let adminUser: any
let customerUser: any
if (!totalDocs) {
// Create admin user
adminUser = await payload.create({
collection: 'users',
data: {
...devUser,
role: 'admin',
firstName: 'Admin',
lastName: 'User',
},
})
console.log('✅ Created admin user:', devUser.email)
// Create sample customer user
customerUser = await payload.create({
collection: 'users',
data: {
email: 'customer@example.com',
password: 'test',
role: 'customer',
firstName: 'John',
lastName: 'Customer',
},
})
console.log('✅ Created customer user')
} else {
// Get existing users
const existingAdmin = await payload.find({
collection: 'users',
where: { email: { equals: devUser.email } },
limit: 1,
})
adminUser = existingAdmin.docs[0]
const existingCustomer = await payload.find({
collection: 'users',
where: { email: { equals: 'customer@example.com' } },
limit: 1,
})
customerUser = existingCustomer.docs[0]
}
// Check if sample notifications already exist
const existingNotifications = await payload.count({ collection: 'notifications' })
if (existingNotifications.totalDocs === 0) {
// Create sample notifications
await payload.create({
collection: 'notifications',
data: {
title: 'Welcome to the Demo!',
message: {
root: {
type: 'root',
children: [
{
type: 'paragraph',
children: [
{
type: 'text',
text: 'Welcome to the notifications plugin demo! This notification was created during the seeding process. Try creating your own notifications and watch the automatic push notifications work.',
},
],
direction: 'ltr',
format: '',
indent: 0,
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
version: 1,
},
},
recipient: customerUser.id,
channel: 'general',
isRead: false,
},
})
await payload.create({
collection: 'notifications',
data: {
title: 'Orders Channel Demo',
message: {
root: {
type: 'root',
children: [
{
type: 'paragraph',
children: [
{
type: 'text',
text: 'This is a sample notification for the Orders channel. Users subscribed to the Orders channel will receive notifications like this one.',
},
],
direction: 'ltr',
format: '',
indent: 0,
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
version: 1,
},
},
recipient: customerUser.id,
channel: 'orders',
isRead: false,
},
})
await payload.create({
collection: 'notifications',
data: {
title: 'New Product Recommendation',
message: {
root: {
type: 'root',
children: [
{
type: 'paragraph',
children: [
{
type: 'text',
text: 'This is a sample notification for the Products channel. This notification has been marked as read to demonstrate the read/unread functionality.',
},
],
direction: 'ltr',
format: '',
indent: 0,
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
version: 1,
},
},
recipient: customerUser.id,
channel: 'products',
isRead: true,
readAt: new Date().toISOString(),
},
})
console.log('✅ Created sample notifications')
console.log('\n🎉 Database seeded successfully!')
console.log('\n📝 You can now:')
console.log(' • Login as admin: dev@payloadcms.com / test')
console.log(' • View notifications in the admin panel')
console.log(' • Create new notifications and watch automatic push notifications!')
console.log(' • Test the channel-based subscription system')
console.log(' • Try the demo at /demo to subscribe to push notifications')
} else {
console.log('✅ Sample data already exists, skipping seed')
}
}

43
dev/tsconfig.json Normal file
View File

@@ -0,0 +1,43 @@
{
"extends": "../tsconfig.json",
"exclude": [],
"include": [
"**/*.js",
"**/*.jsx",
"**/*.mjs",
"**/*.cjs",
"**/*.ts",
"**/*.tsx",
"next.config.mjs",
".next/types/**/*.ts"
],
"compilerOptions": {
"baseUrl": "./",
"rootDir": "./",
"paths": {
"@payload-config": [
"./payload.config.ts"
],
"@xtr-dev/payload-notifications": [
"../src/index.ts"
],
"@xtr-dev/payload-notifications/client": [
"../src/exports/client.ts"
],
"@xtr-dev/payload-notifications/rsc": [
"../src/exports/rsc.ts"
]
},
"noEmit": true,
"emitDeclarationOnly": false,
"allowJs": true,
"incremental": true,
"isolatedModules": true,
"jsx": "preserve",
"plugins": [
{
"name": "next"
}
]
}
}