build: production build with Phase 1 2025 Bible Reader implementation complete
Includes all Phase 1 features: - Search-first navigation with auto-complete - Responsive reading interface (desktop/tablet/mobile) - 4 customization presets + full fine-tuning controls - Layered details panel with notes, bookmarks, highlights - Smart offline caching with IndexedDB and auto-sync - Full accessibility (WCAG 2.1 AA) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
63
payload/collections/BibleBooks.ts
Normal file
63
payload/collections/BibleBooks.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { CollectionConfig } from 'payload';
|
||||
|
||||
export const BibleBooks: CollectionConfig = {
|
||||
slug: 'bible-books',
|
||||
admin: {
|
||||
useAsTitle: 'name',
|
||||
defaultColumns: ['name', 'abbreviation', 'testament', 'chapterCount', 'order'],
|
||||
group: 'Bible Content',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'bookId',
|
||||
type: 'number',
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
localized: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'abbreviation',
|
||||
type: 'text',
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'testament',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Old Testament', value: 'OT' },
|
||||
{ label: 'New Testament', value: 'NT' },
|
||||
],
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'chapterCount',
|
||||
type: 'number',
|
||||
required: true,
|
||||
min: 1,
|
||||
},
|
||||
{
|
||||
name: 'order',
|
||||
type: 'number',
|
||||
required: true,
|
||||
index: true,
|
||||
admin: {
|
||||
description: 'Display order in Bible',
|
||||
},
|
||||
},
|
||||
],
|
||||
access: {
|
||||
read: () => true,
|
||||
create: ({ req }) => req.user?.role === 'admin' || req.user?.role === 'super-admin',
|
||||
update: ({ req }) => req.user?.role === 'admin' || req.user?.role === 'super-admin',
|
||||
delete: ({ req }) => req.user?.role === 'super-admin',
|
||||
},
|
||||
};
|
||||
94
payload/collections/BibleVerses.ts
Normal file
94
payload/collections/BibleVerses.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { CollectionConfig } from 'payload';
|
||||
|
||||
export const BibleVerses: CollectionConfig = {
|
||||
slug: 'bible-verses',
|
||||
admin: {
|
||||
useAsTitle: 'reference',
|
||||
defaultColumns: ['reference', 'version', 'book', 'chapter'],
|
||||
group: 'Bible Content',
|
||||
pagination: {
|
||||
defaultLimit: 50,
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'book',
|
||||
type: 'relationship',
|
||||
relationTo: 'bible-books',
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'chapter',
|
||||
type: 'number',
|
||||
required: true,
|
||||
min: 1,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'verse',
|
||||
type: 'number',
|
||||
required: true,
|
||||
min: 1,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'text',
|
||||
type: 'textarea',
|
||||
required: true,
|
||||
localized: true,
|
||||
},
|
||||
{
|
||||
name: 'version',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Cornilescu (VDC)', value: 'VDC' },
|
||||
{ label: 'NASB', value: 'NASB' },
|
||||
{ label: 'RVR', value: 'RVR' },
|
||||
{ label: 'NR', value: 'NR' },
|
||||
],
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'reference',
|
||||
type: 'text',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
async ({ data, siblingData, req }) => {
|
||||
if (!data) return '';
|
||||
|
||||
if (siblingData?.book && data.chapter && data.verse) {
|
||||
const book = await req.payload.findByID({
|
||||
collection: 'bible-books',
|
||||
id: siblingData.book,
|
||||
});
|
||||
|
||||
if (book) {
|
||||
return `${book.name} ${data.chapter}:${data.verse}`;
|
||||
}
|
||||
}
|
||||
return data.reference || '';
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'embedding',
|
||||
type: 'json',
|
||||
admin: {
|
||||
hidden: true,
|
||||
description: 'Vector embedding for semantic search',
|
||||
},
|
||||
},
|
||||
],
|
||||
access: {
|
||||
read: () => true,
|
||||
create: ({ req }) => req.user?.role === 'admin' || req.user?.role === 'super-admin',
|
||||
update: ({ req }) => req.user?.role === 'admin' || req.user?.role === 'super-admin',
|
||||
delete: ({ req }) => req.user?.role === 'super-admin',
|
||||
},
|
||||
};
|
||||
60
payload/collections/Bookmarks.ts
Normal file
60
payload/collections/Bookmarks.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { CollectionConfig } from 'payload';
|
||||
|
||||
export const Bookmarks: CollectionConfig = {
|
||||
slug: 'bookmarks',
|
||||
admin: {
|
||||
useAsTitle: 'id',
|
||||
defaultColumns: ['user', 'book', 'chapter', 'createdAt'],
|
||||
group: 'User Content',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'user',
|
||||
type: 'relationship',
|
||||
relationTo: 'users',
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'book',
|
||||
type: 'relationship',
|
||||
relationTo: 'bible-books',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'chapter',
|
||||
type: 'number',
|
||||
required: true,
|
||||
min: 1,
|
||||
},
|
||||
{
|
||||
name: 'verse',
|
||||
type: 'number',
|
||||
min: 1,
|
||||
},
|
||||
{
|
||||
name: 'note',
|
||||
type: 'textarea',
|
||||
},
|
||||
],
|
||||
access: {
|
||||
read: ({ req }) => {
|
||||
if (!req.user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (req.user.role === 'admin' || req.user.role === 'super-admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
equals: req.user.id,
|
||||
},
|
||||
};
|
||||
},
|
||||
create: ({ req }) => !!req.user,
|
||||
update: ({ req }) => !!req.user,
|
||||
delete: ({ req }) => !!req.user,
|
||||
},
|
||||
};
|
||||
85
payload/collections/CheckoutSessions.ts
Normal file
85
payload/collections/CheckoutSessions.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { CollectionConfig } from 'payload';
|
||||
|
||||
export const CheckoutSessions: CollectionConfig = {
|
||||
slug: 'checkout-sessions',
|
||||
admin: {
|
||||
useAsTitle: 'sessionId',
|
||||
defaultColumns: ['sessionId', 'user', 'type', 'status', 'createdAt'],
|
||||
group: 'E-Commerce',
|
||||
pagination: {
|
||||
defaultLimit: 100,
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'sessionId',
|
||||
type: 'text',
|
||||
unique: true,
|
||||
required: true,
|
||||
index: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'user',
|
||||
type: 'relationship',
|
||||
relationTo: 'users',
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'price',
|
||||
type: 'relationship',
|
||||
relationTo: 'prices',
|
||||
},
|
||||
{
|
||||
name: 'type',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Subscription', value: 'subscription' },
|
||||
{ label: 'Donation', value: 'donation' },
|
||||
{ label: 'One-time Purchase', value: 'one-time' },
|
||||
],
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Pending', value: 'pending' },
|
||||
{ label: 'Completed', value: 'completed' },
|
||||
{ label: 'Expired', value: 'expired' },
|
||||
{ label: 'Failed', value: 'failed' },
|
||||
],
|
||||
defaultValue: 'pending',
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'metadata',
|
||||
type: 'json',
|
||||
},
|
||||
],
|
||||
access: {
|
||||
read: ({ req }) => {
|
||||
if (!req.user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (req.user.role === 'admin' || req.user.role === 'super-admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
equals: req.user.id,
|
||||
},
|
||||
};
|
||||
},
|
||||
create: ({ req }) => req.user?.role === 'admin' || req.user?.role === 'super-admin',
|
||||
update: ({ req }) => req.user?.role === 'admin' || req.user?.role === 'super-admin',
|
||||
delete: ({ req }) => req.user?.role === 'super-admin',
|
||||
},
|
||||
};
|
||||
80
payload/collections/Customers.ts
Normal file
80
payload/collections/Customers.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { CollectionConfig } from 'payload';
|
||||
|
||||
export const Customers: CollectionConfig = {
|
||||
slug: 'customers',
|
||||
admin: {
|
||||
useAsTitle: 'email',
|
||||
defaultColumns: ['email', 'name', 'stripeCustomerId', 'createdAt'],
|
||||
group: 'E-Commerce',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'stripeCustomerId',
|
||||
type: 'text',
|
||||
unique: true,
|
||||
required: true,
|
||||
index: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
type: 'email',
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'user',
|
||||
type: 'relationship',
|
||||
relationTo: 'users',
|
||||
hasMany: false,
|
||||
unique: true,
|
||||
admin: {
|
||||
description: 'Associated user account',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'metadata',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'Custom Stripe metadata',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
},
|
||||
],
|
||||
access: {
|
||||
read: ({ req }) => {
|
||||
if (!req.user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (req.user.role === 'admin' || req.user.role === 'super-admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Users can only read their own customer record
|
||||
return {
|
||||
user: {
|
||||
equals: req.user.id,
|
||||
},
|
||||
};
|
||||
},
|
||||
create: ({ req }) => {
|
||||
return req.user?.role === 'admin' || req.user?.role === 'super-admin';
|
||||
},
|
||||
update: ({ req }) => {
|
||||
return req.user?.role === 'admin' || req.user?.role === 'super-admin';
|
||||
},
|
||||
delete: ({ req }) => {
|
||||
return req.user?.role === 'super-admin';
|
||||
},
|
||||
},
|
||||
};
|
||||
74
payload/collections/Donations.ts
Normal file
74
payload/collections/Donations.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { CollectionConfig } from 'payload';
|
||||
|
||||
export const Donations: CollectionConfig = {
|
||||
slug: 'donations',
|
||||
admin: {
|
||||
useAsTitle: 'donorName',
|
||||
defaultColumns: ['donorName', 'amount', 'currency', 'status', 'createdAt'],
|
||||
group: 'E-Commerce',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'donorName',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'donorEmail',
|
||||
type: 'email',
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
type: 'number',
|
||||
required: true,
|
||||
min: 0,
|
||||
admin: {
|
||||
description: 'Amount in dollars',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'currency',
|
||||
type: 'text',
|
||||
defaultValue: 'USD',
|
||||
},
|
||||
{
|
||||
name: 'stripeSessionId',
|
||||
type: 'text',
|
||||
unique: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'stripePaymentIntentId',
|
||||
type: 'text',
|
||||
unique: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Pending', value: 'pending' },
|
||||
{ label: 'Completed', value: 'completed' },
|
||||
{ label: 'Failed', value: 'failed' },
|
||||
],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'message',
|
||||
type: 'textarea',
|
||||
},
|
||||
{
|
||||
name: 'anonymous',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
},
|
||||
],
|
||||
access: {
|
||||
read: ({ req }) => req.user?.role === 'admin' || req.user?.role === 'super-admin',
|
||||
create: () => true,
|
||||
update: ({ req }) => req.user?.role === 'admin' || req.user?.role === 'super-admin',
|
||||
delete: ({ req }) => req.user?.role === 'super-admin',
|
||||
},
|
||||
};
|
||||
78
payload/collections/FailedPayments.ts
Normal file
78
payload/collections/FailedPayments.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { CollectionConfig } from 'payload';
|
||||
|
||||
export const FailedPayments: CollectionConfig = {
|
||||
slug: 'failed-payments',
|
||||
admin: {
|
||||
useAsTitle: 'id',
|
||||
defaultColumns: ['stripePaymentIntentId', 'amount', 'errorCode', 'createdAt'],
|
||||
group: 'E-Commerce',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'stripePaymentIntentId',
|
||||
type: 'text',
|
||||
unique: true,
|
||||
required: true,
|
||||
index: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'customerId',
|
||||
type: 'text',
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
type: 'number',
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'Amount in cents',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'currency',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'error',
|
||||
type: 'textarea',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'errorCode',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'retryCount',
|
||||
type: 'number',
|
||||
defaultValue: 0,
|
||||
min: 0,
|
||||
},
|
||||
{
|
||||
name: 'resolved',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'resolvedAt',
|
||||
type: 'date',
|
||||
},
|
||||
{
|
||||
name: 'notes',
|
||||
type: 'textarea',
|
||||
admin: {
|
||||
description: 'Internal notes about this failure',
|
||||
},
|
||||
},
|
||||
],
|
||||
access: {
|
||||
read: ({ req }) => req.user?.role === 'admin' || req.user?.role === 'super-admin',
|
||||
create: ({ req }) => req.user?.role === 'admin' || req.user?.role === 'super-admin',
|
||||
update: ({ req }) => req.user?.role === 'admin' || req.user?.role === 'super-admin',
|
||||
delete: ({ req }) => req.user?.role === 'super-admin',
|
||||
},
|
||||
};
|
||||
62
payload/collections/Highlights.ts
Normal file
62
payload/collections/Highlights.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { CollectionConfig } from 'payload';
|
||||
|
||||
export const Highlights: CollectionConfig = {
|
||||
slug: 'highlights',
|
||||
admin: {
|
||||
useAsTitle: 'id',
|
||||
defaultColumns: ['user', 'color', 'createdAt'],
|
||||
group: 'User Content',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'user',
|
||||
type: 'relationship',
|
||||
relationTo: 'users',
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'verse',
|
||||
type: 'relationship',
|
||||
relationTo: 'bible-verses',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'color',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Yellow', value: 'yellow' },
|
||||
{ label: 'Green', value: 'green' },
|
||||
{ label: 'Blue', value: 'blue' },
|
||||
{ label: 'Red', value: 'red' },
|
||||
{ label: 'Pink', value: 'pink' },
|
||||
],
|
||||
defaultValue: 'yellow',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'note',
|
||||
type: 'textarea',
|
||||
},
|
||||
],
|
||||
access: {
|
||||
read: ({ req }) => {
|
||||
if (!req.user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (req.user.role === 'admin' || req.user.role === 'super-admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
equals: req.user.id,
|
||||
},
|
||||
};
|
||||
},
|
||||
create: ({ req }) => !!req.user,
|
||||
update: ({ req }) => !!req.user,
|
||||
delete: ({ req }) => !!req.user,
|
||||
},
|
||||
};
|
||||
122
payload/collections/Prices.ts
Normal file
122
payload/collections/Prices.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { CollectionConfig } from 'payload';
|
||||
|
||||
export const Prices: CollectionConfig = {
|
||||
slug: 'prices',
|
||||
admin: {
|
||||
useAsTitle: 'displayName',
|
||||
defaultColumns: ['displayName', 'stripePriceId', 'unitAmount', 'currency', 'active'],
|
||||
group: 'E-Commerce',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'displayName',
|
||||
type: 'text',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
({ data, siblingData }) => {
|
||||
const amount = ((siblingData.unitAmount || 0) / 100).toFixed(2);
|
||||
const currency = (siblingData.currency || 'USD').toUpperCase();
|
||||
const interval = siblingData.recurring?.interval;
|
||||
|
||||
if (interval) {
|
||||
const intervalCount = siblingData.recurring?.intervalCount || 1;
|
||||
const label = intervalCount > 1 ? `${intervalCount} ${interval}s` : interval;
|
||||
return `${currency} ${amount}/${label}`;
|
||||
}
|
||||
return `${currency} ${amount}`;
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'product',
|
||||
type: 'relationship',
|
||||
relationTo: 'products',
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'stripePriceId',
|
||||
type: 'text',
|
||||
unique: true,
|
||||
index: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'unitAmount',
|
||||
type: 'number',
|
||||
required: true,
|
||||
min: 0,
|
||||
admin: {
|
||||
description: 'Amount in cents (e.g., 9999 = $99.99)',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'currency',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'USD ($)', value: 'usd' },
|
||||
{ label: 'EUR (€)', value: 'eur' },
|
||||
{ label: 'GBP (£)', value: 'gbp' },
|
||||
{ label: 'RON (lei)', value: 'ron' },
|
||||
],
|
||||
defaultValue: 'usd',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'recurring',
|
||||
type: 'group',
|
||||
admin: {
|
||||
description: 'Leave empty for one-time prices',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'interval',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Daily', value: 'day' },
|
||||
{ label: 'Weekly', value: 'week' },
|
||||
{ label: 'Monthly', value: 'month' },
|
||||
{ label: 'Yearly', value: 'year' },
|
||||
],
|
||||
required: true,
|
||||
admin: {
|
||||
condition: (_, siblingData) => !!siblingData?.interval !== false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'intervalCount',
|
||||
type: 'number',
|
||||
defaultValue: 1,
|
||||
min: 1,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'trialPeriodDays',
|
||||
type: 'number',
|
||||
min: 0,
|
||||
admin: {
|
||||
description: 'Number of trial days (optional)',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'active',
|
||||
type: 'checkbox',
|
||||
defaultValue: true,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
access: {
|
||||
read: () => true, // Prices are public
|
||||
create: ({ req }) => req.user?.role === 'admin' || req.user?.role === 'super-admin',
|
||||
update: ({ req }) => req.user?.role === 'admin' || req.user?.role === 'super-admin',
|
||||
delete: ({ req }) => req.user?.role === 'super-admin',
|
||||
},
|
||||
};
|
||||
102
payload/collections/Products.ts
Normal file
102
payload/collections/Products.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { CollectionConfig } from 'payload';
|
||||
|
||||
export const Products: CollectionConfig = {
|
||||
slug: 'products',
|
||||
admin: {
|
||||
useAsTitle: 'name',
|
||||
defaultColumns: ['name', 'stripeProductId', 'active', 'createdAt'],
|
||||
group: 'E-Commerce',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
localized: true,
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'richText',
|
||||
localized: true,
|
||||
},
|
||||
{
|
||||
name: 'stripeProductId',
|
||||
type: 'text',
|
||||
unique: true,
|
||||
index: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'active',
|
||||
type: 'checkbox',
|
||||
defaultValue: true,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'metadata',
|
||||
type: 'group',
|
||||
fields: [
|
||||
{
|
||||
name: 'planType',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Free', value: 'free' },
|
||||
{ label: 'Premium', value: 'premium' },
|
||||
{ label: 'Enterprise', value: 'enterprise' },
|
||||
],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'features',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{
|
||||
name: 'feature',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'included',
|
||||
type: 'checkbox',
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
name: 'limit',
|
||||
type: 'number',
|
||||
admin: {
|
||||
condition: (data, siblingData) => !siblingData.included,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'prices',
|
||||
type: 'relationship',
|
||||
relationTo: 'prices',
|
||||
hasMany: true,
|
||||
admin: {
|
||||
description: 'Associated price points for this product',
|
||||
},
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
async ({ data, operation }) => {
|
||||
if (operation === 'create' && !data.stripeProductId) {
|
||||
console.log('Product created:', data.name, '- Stripe ID should be synced from Stripe plugin');
|
||||
}
|
||||
return data;
|
||||
},
|
||||
],
|
||||
},
|
||||
access: {
|
||||
read: () => true, // Products are public
|
||||
create: ({ req }) => req.user?.role === 'admin' || req.user?.role === 'super-admin',
|
||||
update: ({ req }) => req.user?.role === 'admin' || req.user?.role === 'super-admin',
|
||||
delete: ({ req }) => req.user?.role === 'super-admin',
|
||||
},
|
||||
};
|
||||
155
payload/collections/Subscriptions.ts
Normal file
155
payload/collections/Subscriptions.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { CollectionConfig } from 'payload';
|
||||
|
||||
export const Subscriptions: CollectionConfig = {
|
||||
slug: 'subscriptions',
|
||||
admin: {
|
||||
useAsTitle: 'id',
|
||||
defaultColumns: ['customer', 'status', 'currentPeriodEnd', 'active'],
|
||||
group: 'E-Commerce',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'stripeSubscriptionId',
|
||||
type: 'text',
|
||||
unique: true,
|
||||
required: true,
|
||||
index: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'customer',
|
||||
type: 'relationship',
|
||||
relationTo: 'customers',
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'user',
|
||||
type: 'relationship',
|
||||
relationTo: 'users',
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'prices',
|
||||
type: 'relationship',
|
||||
relationTo: 'prices',
|
||||
hasMany: true,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Active', value: 'active' },
|
||||
{ label: 'Past Due', value: 'past_due' },
|
||||
{ label: 'Canceled', value: 'canceled' },
|
||||
{ label: 'Incomplete', value: 'incomplete' },
|
||||
{ label: 'Incomplete Expired', value: 'incomplete_expired' },
|
||||
{ label: 'Trialing', value: 'trialing' },
|
||||
{ label: 'Unpaid', value: 'unpaid' },
|
||||
{ label: 'Paused', value: 'paused' },
|
||||
],
|
||||
required: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'currentPeriodStart',
|
||||
type: 'date',
|
||||
required: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'currentPeriodEnd',
|
||||
type: 'date',
|
||||
required: true,
|
||||
index: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'canceledAt',
|
||||
type: 'date',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'cancelAtPeriodEnd',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'metadata',
|
||||
type: 'group',
|
||||
fields: [
|
||||
{
|
||||
name: 'planName',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'conversationCount',
|
||||
type: 'number',
|
||||
defaultValue: 0,
|
||||
admin: {
|
||||
description: 'Monthly conversation count for free tier',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'lastResetDate',
|
||||
type: 'date',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
afterChange: [
|
||||
async ({ doc, operation, req }) => {
|
||||
if (operation === 'create' || operation === 'update') {
|
||||
// Update user's subscription reference
|
||||
if (doc.user) {
|
||||
await req.payload.update({
|
||||
collection: 'users',
|
||||
id: doc.user,
|
||||
data: {
|
||||
subscription: doc.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
access: {
|
||||
read: ({ req }) => {
|
||||
if (!req.user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (req.user.role === 'admin' || req.user.role === 'super-admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
equals: req.user.id,
|
||||
},
|
||||
};
|
||||
},
|
||||
create: ({ req }) => req.user?.role === 'admin' || req.user?.role === 'super-admin',
|
||||
update: ({ req }) => req.user?.role === 'admin' || req.user?.role === 'super-admin',
|
||||
delete: () => false, // Never delete subscription records
|
||||
},
|
||||
};
|
||||
207
payload/collections/Users.ts
Normal file
207
payload/collections/Users.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { CollectionConfig } from 'payload';
|
||||
|
||||
export const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
auth: {
|
||||
tokenExpiration: 604800, // 7 days
|
||||
cookies: {
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: 'email',
|
||||
defaultColumns: ['email', 'name', 'role', 'createdAt'],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
localized: false,
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
type: 'email',
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'role',
|
||||
type: 'select',
|
||||
options: [
|
||||
{
|
||||
label: 'User',
|
||||
value: 'user',
|
||||
},
|
||||
{
|
||||
label: 'Admin',
|
||||
value: 'admin',
|
||||
},
|
||||
{
|
||||
label: 'Super Admin',
|
||||
value: 'super-admin',
|
||||
},
|
||||
],
|
||||
defaultValue: 'user',
|
||||
required: true,
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'favoriteVersion',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Cornilescu', value: 'VDC' },
|
||||
{ label: 'NASB', value: 'NASB' },
|
||||
{ label: 'RVR', value: 'RVR' },
|
||||
{ label: 'NR', value: 'NR' },
|
||||
],
|
||||
defaultValue: 'VDC',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'stripeCustomerId',
|
||||
type: 'text',
|
||||
unique: true,
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
readOnly: true,
|
||||
description: 'Automatically set by Stripe integration',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'subscription',
|
||||
type: 'relationship',
|
||||
relationTo: 'subscriptions',
|
||||
hasMany: false,
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'profileSettings',
|
||||
type: 'group',
|
||||
fields: [
|
||||
{
|
||||
name: 'fontSize',
|
||||
type: 'number',
|
||||
defaultValue: 16,
|
||||
min: 12,
|
||||
max: 24,
|
||||
},
|
||||
{
|
||||
name: 'theme',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Light', value: 'light' },
|
||||
{ label: 'Dark', value: 'dark' },
|
||||
],
|
||||
defaultValue: 'light',
|
||||
},
|
||||
{
|
||||
name: 'showVerseNumbers',
|
||||
type: 'checkbox',
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
name: 'enableNotifications',
|
||||
type: 'checkbox',
|
||||
defaultValue: true,
|
||||
},
|
||||
],
|
||||
label: 'Profile Settings',
|
||||
},
|
||||
{
|
||||
name: 'activityLog',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{
|
||||
name: 'action',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'timestamp',
|
||||
type: 'date',
|
||||
required: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
admin: {
|
||||
readOnly: true,
|
||||
description: 'Automatically tracked user activities',
|
||||
},
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
async ({ data, operation }) => {
|
||||
if (operation === 'create' && !data.email) {
|
||||
throw new Error('Email is required');
|
||||
}
|
||||
return data;
|
||||
},
|
||||
],
|
||||
afterChange: [
|
||||
async ({ doc, operation }) => {
|
||||
if (operation === 'create') {
|
||||
console.log(`New user created: ${doc.email}`);
|
||||
}
|
||||
return doc;
|
||||
},
|
||||
],
|
||||
},
|
||||
access: {
|
||||
read: ({ req }) => {
|
||||
// Users can read their own data, admins can read all
|
||||
if (!req.user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (req.user.role === 'admin' || req.user.role === 'super-admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
id: {
|
||||
equals: req.user.id,
|
||||
},
|
||||
};
|
||||
},
|
||||
create: () => {
|
||||
// Public can create accounts (registration)
|
||||
return true;
|
||||
},
|
||||
update: ({ req }) => {
|
||||
// Users can update their own data, admins can update all
|
||||
if (!req.user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (req.user.role === 'admin' || req.user.role === 'super-admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
id: {
|
||||
equals: req.user.id,
|
||||
},
|
||||
};
|
||||
},
|
||||
delete: ({ req }) => {
|
||||
// Only super admins can delete users
|
||||
if (!req.user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return req.user.role === 'super-admin';
|
||||
},
|
||||
},
|
||||
};
|
||||
12
payload/collections/index.ts
Normal file
12
payload/collections/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { Users } from './Users';
|
||||
export { Customers } from './Customers';
|
||||
export { Subscriptions } from './Subscriptions';
|
||||
export { Products } from './Products';
|
||||
export { Prices } from './Prices';
|
||||
export { BibleBooks } from './BibleBooks';
|
||||
export { BibleVerses } from './BibleVerses';
|
||||
export { Bookmarks } from './Bookmarks';
|
||||
export { Highlights } from './Highlights';
|
||||
export { Donations } from './Donations';
|
||||
export { CheckoutSessions } from './CheckoutSessions';
|
||||
export { FailedPayments } from './FailedPayments';
|
||||
Reference in New Issue
Block a user