@nextsparkjs/theme-blog 0.1.0-beta.19 → 0.1.0-beta.190
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/authors/[username]/route.ts +5 -2
- package/api/authors/docs.md +135 -0
- package/api/authors/presets.ts +45 -0
- package/api/authors/route.ts +4 -1
- package/api/posts/public/docs.md +124 -0
- package/api/posts/public/presets.ts +65 -0
- package/api/posts/public/route.ts +4 -1
- package/components/ExportPostsButton.tsx +2 -1
- package/components/ImportPostsDialog.tsx +2 -1
- package/components/editor/FeaturedImageUpload.tsx +2 -1
- package/components/editor/WysiwygEditor.tsx +56 -7
- package/components/public/AuthorBio.tsx +3 -2
- package/components/public/AuthorCard.tsx +3 -2
- package/components/public/BlogFooter.tsx +2 -1
- package/components/public/PostCard.tsx +8 -7
- package/components/public/RelatedPosts.tsx +2 -1
- package/config/app.config.ts +8 -15
- package/config/billing.config.ts +4 -7
- package/config/dashboard.config.ts +13 -0
- package/config/permissions.config.ts +11 -0
- package/entities/categories/api/docs.md +119 -0
- package/entities/categories/api/presets.ts +67 -0
- package/entities/posts/api/docs.md +174 -0
- package/entities/posts/api/presets.ts +137 -0
- package/lib/sanitize-post-html.ts +52 -0
- package/lib/selectors.ts +2 -3
- package/migrations/090_billing_sample_data.sql +99 -0
- package/migrations/999_theme_sample_data.sql +13 -13
- package/nextsparkjs-theme-blog-0.1.0-beta.137.tgz +0 -0
- package/package.json +13 -5
- package/styles/globals.css +58 -0
- package/templates/(public)/author/[username]/page.tsx +6 -5
- package/templates/(public)/authors/page.tsx +3 -2
- package/templates/(public)/page.tsx +2 -1
- package/templates/(public)/posts/[slug]/page.tsx +7 -5
- package/templates/dashboard/(main)/page.tsx +2 -1
- package/templates/dashboard/(main)/posts/[id]/edit/page.tsx +4 -3
- package/templates/dashboard/(main)/posts/create/page.tsx +2 -1
- package/templates/dashboard/(main)/posts/page.tsx +7 -6
- package/tests/cypress/e2e/README.md +170 -0
- package/tests/cypress/e2e/categories/categories-crud.cy.ts +322 -0
- package/tests/cypress/e2e/categories/categories-crud.md +73 -0
- package/tests/cypress/e2e/posts/posts-crud.cy.ts +460 -0
- package/tests/cypress/e2e/posts/posts-crud.md +115 -0
- package/tests/cypress/e2e/posts/posts-editor.cy.ts +290 -0
- package/tests/cypress/e2e/posts/posts-editor.md +139 -0
- package/tests/cypress/e2e/posts/posts-status-workflow.cy.ts +302 -0
- package/tests/cypress/e2e/posts/posts-status-workflow.md +83 -0
- package/tests/cypress/fixtures/blocks.json +9 -0
- package/tests/cypress/fixtures/entities.json +51 -0
- package/tests/cypress/src/FeaturedImageUpload.js +131 -0
- package/tests/cypress/src/PostEditor.js +386 -0
- package/tests/cypress/src/PostsList.js +350 -0
- package/tests/cypress/src/WysiwygEditor.js +373 -0
- package/tests/cypress/src/components/EntityForm.ts +378 -0
- package/tests/cypress/src/components/EntityList.ts +378 -0
- package/tests/cypress/src/components/PostEditorPOM.ts +447 -0
- package/tests/cypress/src/components/PostsPOM.ts +362 -0
- package/tests/cypress/src/components/index.ts +18 -0
- package/tests/cypress/src/index.js +33 -0
- package/tests/cypress/src/selectors.ts +49 -0
- package/tests/cypress/src/session-helpers.ts +151 -0
- package/tests/cypress/support/e2e.ts +90 -0
- package/tests/cypress.config.ts +154 -0
- package/tests/jest/__mocks__/jose.js +22 -0
- package/tests/jest/__mocks__/next-server.js +56 -0
- package/tests/jest/components/WysiwygEditor.test.tsx +117 -0
- package/tests/jest/jest.config.cjs +148 -0
- package/tests/jest/lib/sanitize-post-html.test.ts +47 -0
- package/tests/jest/setup.ts +170 -0
- package/tests/jest/tsconfig.jest.json +6 -0
- package/tests/tsconfig.json +15 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API Presets for Posts Entity
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { defineApiEndpoint } from '@nextsparkjs/core/types/api-presets'
|
|
6
|
+
|
|
7
|
+
export default defineApiEndpoint({
|
|
8
|
+
summary: 'Manage blog posts with content builder and publication workflow',
|
|
9
|
+
presets: [
|
|
10
|
+
{
|
|
11
|
+
id: 'list-all',
|
|
12
|
+
title: 'List All Posts',
|
|
13
|
+
description: 'Get all posts with pagination',
|
|
14
|
+
method: 'GET',
|
|
15
|
+
params: {
|
|
16
|
+
limit: 20
|
|
17
|
+
},
|
|
18
|
+
tags: ['read', 'list']
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
id: 'list-published',
|
|
22
|
+
title: 'List Published Posts',
|
|
23
|
+
description: 'Get all published posts',
|
|
24
|
+
method: 'GET',
|
|
25
|
+
params: {
|
|
26
|
+
status: 'published'
|
|
27
|
+
},
|
|
28
|
+
tags: ['read', 'filter']
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: 'list-drafts',
|
|
32
|
+
title: 'List Draft Posts',
|
|
33
|
+
description: 'Get all draft posts',
|
|
34
|
+
method: 'GET',
|
|
35
|
+
params: {
|
|
36
|
+
status: 'draft'
|
|
37
|
+
},
|
|
38
|
+
tags: ['read', 'filter']
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: 'list-featured',
|
|
42
|
+
title: 'List Featured Posts',
|
|
43
|
+
description: 'Get all featured posts',
|
|
44
|
+
method: 'GET',
|
|
45
|
+
params: {
|
|
46
|
+
featured: 'true'
|
|
47
|
+
},
|
|
48
|
+
tags: ['read', 'filter']
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: 'search-by-title',
|
|
52
|
+
title: 'Search by Title',
|
|
53
|
+
description: 'Search posts by title',
|
|
54
|
+
method: 'GET',
|
|
55
|
+
params: {
|
|
56
|
+
search: '{{title}}'
|
|
57
|
+
},
|
|
58
|
+
tags: ['read', 'search']
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: 'create-draft',
|
|
62
|
+
title: 'Create Draft Post',
|
|
63
|
+
description: 'Create a new draft post',
|
|
64
|
+
method: 'POST',
|
|
65
|
+
payload: {
|
|
66
|
+
title: 'New Post Title',
|
|
67
|
+
slug: 'new-post-title',
|
|
68
|
+
excerpt: 'Brief description of the post',
|
|
69
|
+
status: 'draft'
|
|
70
|
+
},
|
|
71
|
+
tags: ['write', 'create']
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: 'publish-post',
|
|
75
|
+
title: 'Publish Post',
|
|
76
|
+
description: 'Publish a draft post',
|
|
77
|
+
method: 'PATCH',
|
|
78
|
+
pathParams: {
|
|
79
|
+
id: '{{id}}'
|
|
80
|
+
},
|
|
81
|
+
payload: {
|
|
82
|
+
status: 'published',
|
|
83
|
+
publishedAt: '{{publishedAt}}'
|
|
84
|
+
},
|
|
85
|
+
tags: ['write', 'update']
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: 'unpublish-post',
|
|
89
|
+
title: 'Unpublish Post',
|
|
90
|
+
description: 'Revert a post to draft',
|
|
91
|
+
method: 'PATCH',
|
|
92
|
+
pathParams: {
|
|
93
|
+
id: '{{id}}'
|
|
94
|
+
},
|
|
95
|
+
payload: {
|
|
96
|
+
status: 'draft'
|
|
97
|
+
},
|
|
98
|
+
tags: ['write', 'update']
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
id: 'set-featured',
|
|
102
|
+
title: 'Set as Featured',
|
|
103
|
+
description: 'Mark a post as featured',
|
|
104
|
+
method: 'PATCH',
|
|
105
|
+
pathParams: {
|
|
106
|
+
id: '{{id}}'
|
|
107
|
+
},
|
|
108
|
+
payload: {
|
|
109
|
+
featured: true
|
|
110
|
+
},
|
|
111
|
+
tags: ['write', 'update']
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
id: 'update-content',
|
|
115
|
+
title: 'Update Content',
|
|
116
|
+
description: 'Update post content blocks',
|
|
117
|
+
method: 'PATCH',
|
|
118
|
+
pathParams: {
|
|
119
|
+
id: '{{id}}'
|
|
120
|
+
},
|
|
121
|
+
payload: {
|
|
122
|
+
content: '{{content}}'
|
|
123
|
+
},
|
|
124
|
+
tags: ['write', 'update']
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
id: 'delete-post',
|
|
128
|
+
title: 'Delete Post',
|
|
129
|
+
description: 'Delete a post record',
|
|
130
|
+
method: 'DELETE',
|
|
131
|
+
pathParams: {
|
|
132
|
+
id: '{{id}}'
|
|
133
|
+
},
|
|
134
|
+
tags: ['write', 'delete']
|
|
135
|
+
}
|
|
136
|
+
]
|
|
137
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import DOMPurify from 'dompurify'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A post body is HTML written by anyone who can create a post, a team member
|
|
5
|
+
* and not only an owner, so every place that turns it back into DOM sanitises
|
|
6
|
+
* it first: the public page, the editor and the editor's preview.
|
|
7
|
+
*
|
|
8
|
+
* DOMPurify needs a DOM. Where there is none (a server render pass) this
|
|
9
|
+
* returns '' rather than the raw string, failing closed.
|
|
10
|
+
*/
|
|
11
|
+
export function sanitizePostHtml(html: string | null | undefined): string {
|
|
12
|
+
if (!html) return ''
|
|
13
|
+
if (!DOMPurify.isSupported) return ''
|
|
14
|
+
return DOMPurify.sanitize(html)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Whether sanitising changes the markup beyond what parsing it already does.
|
|
19
|
+
*
|
|
20
|
+
* Parsing alone rewrites markup that is not unsafe: the browser's editing
|
|
21
|
+
* commands leave a list inside a <p>, and the parser moves it out. So the
|
|
22
|
+
* original and the sanitised result are both parsed and compared as trees; any
|
|
23
|
+
* difference is something the sanitiser took out or altered. It does not read
|
|
24
|
+
* DOMPurify.removed, which DOMPurify documents as unfit for security decisions
|
|
25
|
+
* and which misses changes such as an attribute it rewrites.
|
|
26
|
+
*
|
|
27
|
+
* Both are parsed as a fragment, the way the editable area receives a paste. A
|
|
28
|
+
* document parse moves a leading <script> or <base> into a <head> that a
|
|
29
|
+
* comparison of bodies never sees. A template's content is inert, so nothing in
|
|
30
|
+
* the unsanitised markup runs or loads while it is inspected. Where there is no
|
|
31
|
+
* DOM the answer is true, sending callers down the sanitising path.
|
|
32
|
+
*/
|
|
33
|
+
export function postHtmlHasUnsafeMarkup(html: string | null | undefined): boolean {
|
|
34
|
+
if (!html) return false
|
|
35
|
+
if (!DOMPurify.isSupported || typeof document === 'undefined') return true
|
|
36
|
+
return !sameMarkup(parseFragment(html), parseFragment(DOMPurify.sanitize(html)))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseFragment(markup: string): DocumentFragment {
|
|
40
|
+
const template = document.createElement('template')
|
|
41
|
+
template.innerHTML = markup
|
|
42
|
+
return template.content
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Equal trees, including what each <template> holds: isEqualNode does not look inside one. */
|
|
46
|
+
function sameMarkup(a: DocumentFragment, b: DocumentFragment): boolean {
|
|
47
|
+
if (!a.isEqualNode(b)) return false
|
|
48
|
+
const templatesA = Array.from(a.querySelectorAll('template'))
|
|
49
|
+
const templatesB = Array.from(b.querySelectorAll('template'))
|
|
50
|
+
return templatesA.length === templatesB.length
|
|
51
|
+
&& templatesA.every((template, i) => sameMarkup(template.content, templatesB[i].content))
|
|
52
|
+
}
|
package/lib/selectors.ts
CHANGED
|
@@ -9,8 +9,7 @@
|
|
|
9
9
|
* - Cypress tests (via tests/cypress/src/selectors.ts)
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { createSelectorHelpers } from '@nextsparkjs/core/
|
|
13
|
-
import { CORE_SELECTORS } from '@nextsparkjs/core/lib/test/core-selectors'
|
|
12
|
+
import { createSelectorHelpers, CORE_SELECTORS } from '@nextsparkjs/core/selectors'
|
|
14
13
|
|
|
15
14
|
// =============================================================================
|
|
16
15
|
// BLOCK SELECTORS
|
|
@@ -175,5 +174,5 @@ export const entitySelectors = helpers.entitySelectors
|
|
|
175
174
|
export type ThemeSelectorsType = typeof THEME_SELECTORS
|
|
176
175
|
export type BlockSelectorsType = typeof BLOCK_SELECTORS
|
|
177
176
|
export type EntitySelectorsType = typeof ENTITY_SELECTORS
|
|
178
|
-
export type { Replacements } from '@nextsparkjs/core/
|
|
177
|
+
export type { Replacements } from '@nextsparkjs/core/selectors'
|
|
179
178
|
export { CORE_SELECTORS }
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
-- Migration: 090_billing_sample_data.sql
|
|
2
|
+
-- Description: Sample billing plans for the blog theme (free, pro, enterprise)
|
|
3
|
+
-- Theme: blog
|
|
4
|
+
-- Phase: Theme sample data - runs before 999_theme_sample_data.sql, whose
|
|
5
|
+
-- subscriptions reference these plans by slug.
|
|
6
|
+
--
|
|
7
|
+
-- Mirrors themes/blog/config/billing.config.ts (slugs, prices, features,
|
|
8
|
+
-- limits) so the seeded plans match what the app's billing config declares.
|
|
9
|
+
|
|
10
|
+
-- ============================================
|
|
11
|
+
-- SAMPLE PLANS
|
|
12
|
+
-- ============================================
|
|
13
|
+
|
|
14
|
+
-- Plan: Free
|
|
15
|
+
INSERT INTO public."plans" (
|
|
16
|
+
id,
|
|
17
|
+
slug,
|
|
18
|
+
name,
|
|
19
|
+
description,
|
|
20
|
+
type,
|
|
21
|
+
visibility,
|
|
22
|
+
"priceMonthly",
|
|
23
|
+
"priceYearly",
|
|
24
|
+
"trialDays",
|
|
25
|
+
features,
|
|
26
|
+
limits,
|
|
27
|
+
"sortOrder"
|
|
28
|
+
) VALUES (
|
|
29
|
+
'plan_blog_free',
|
|
30
|
+
'free',
|
|
31
|
+
'Free',
|
|
32
|
+
'Free plan to get started',
|
|
33
|
+
'free',
|
|
34
|
+
'public',
|
|
35
|
+
0,
|
|
36
|
+
0,
|
|
37
|
+
0,
|
|
38
|
+
'["basic_analytics"]'::jsonb,
|
|
39
|
+
'{"posts": 10, "monthly_views": 10000, "storage_gb": 1, "authors": 1}'::jsonb,
|
|
40
|
+
1
|
|
41
|
+
) ON CONFLICT (slug) DO NOTHING;
|
|
42
|
+
|
|
43
|
+
-- Plan: Pro
|
|
44
|
+
INSERT INTO public."plans" (
|
|
45
|
+
id,
|
|
46
|
+
slug,
|
|
47
|
+
name,
|
|
48
|
+
description,
|
|
49
|
+
type,
|
|
50
|
+
visibility,
|
|
51
|
+
"priceMonthly",
|
|
52
|
+
"priceYearly",
|
|
53
|
+
"trialDays",
|
|
54
|
+
features,
|
|
55
|
+
limits,
|
|
56
|
+
"sortOrder"
|
|
57
|
+
) VALUES (
|
|
58
|
+
'plan_blog_pro',
|
|
59
|
+
'pro',
|
|
60
|
+
'Pro',
|
|
61
|
+
'Professional plan for bloggers',
|
|
62
|
+
'paid',
|
|
63
|
+
'public',
|
|
64
|
+
2900,
|
|
65
|
+
29000,
|
|
66
|
+
14,
|
|
67
|
+
'["basic_analytics", "custom_domain", "advanced_seo"]'::jsonb,
|
|
68
|
+
'{"posts": -1, "monthly_views": 100000, "storage_gb": 10, "authors": 5}'::jsonb,
|
|
69
|
+
2
|
|
70
|
+
) ON CONFLICT (slug) DO NOTHING;
|
|
71
|
+
|
|
72
|
+
-- Plan: Enterprise
|
|
73
|
+
INSERT INTO public."plans" (
|
|
74
|
+
id,
|
|
75
|
+
slug,
|
|
76
|
+
name,
|
|
77
|
+
description,
|
|
78
|
+
type,
|
|
79
|
+
visibility,
|
|
80
|
+
"priceMonthly",
|
|
81
|
+
"priceYearly",
|
|
82
|
+
"trialDays",
|
|
83
|
+
features,
|
|
84
|
+
limits,
|
|
85
|
+
"sortOrder"
|
|
86
|
+
) VALUES (
|
|
87
|
+
'plan_blog_enterprise',
|
|
88
|
+
'enterprise',
|
|
89
|
+
'Enterprise',
|
|
90
|
+
'Custom enterprise plan with unlimited features',
|
|
91
|
+
'enterprise',
|
|
92
|
+
'hidden',
|
|
93
|
+
NULL,
|
|
94
|
+
NULL,
|
|
95
|
+
30,
|
|
96
|
+
'["*"]'::jsonb,
|
|
97
|
+
'{"posts": -1, "monthly_views": -1, "storage_gb": -1, "authors": -1}'::jsonb,
|
|
98
|
+
3
|
|
99
|
+
) ON CONFLICT (slug) DO NOTHING;
|
|
@@ -662,15 +662,15 @@ INSERT INTO public."subscriptions" (
|
|
|
662
662
|
"externalSubscriptionId", "externalCustomerId", "createdAt"
|
|
663
663
|
) VALUES
|
|
664
664
|
-- Marcos Tech → Pro Plan $29/mo (active, monthly)
|
|
665
|
-
('sub-blog-marcos', 'team-blog-marcos', 'usr-blog-marcos', '
|
|
665
|
+
('sub-blog-marcos', 'team-blog-marcos', 'usr-blog-marcos', (SELECT id FROM public."plans" WHERE slug = 'pro'), 'active',
|
|
666
666
|
NOW() - INTERVAL '30 days', NOW() + INTERVAL '30 days', 'monthly', 'stripe',
|
|
667
667
|
'sub_stripe_marcos', 'cus_marcos', NOW() - INTERVAL '6 months'),
|
|
668
668
|
-- Lucia Lifestyle → Pro Plan $29/mo (active, monthly)
|
|
669
|
-
('sub-blog-lucia', 'team-blog-lucia', 'usr-blog-lucia', '
|
|
669
|
+
('sub-blog-lucia', 'team-blog-lucia', 'usr-blog-lucia', (SELECT id FROM public."plans" WHERE slug = 'pro'), 'active',
|
|
670
670
|
NOW() - INTERVAL '15 days', NOW() + INTERVAL '15 days', 'monthly', 'stripe',
|
|
671
671
|
'sub_stripe_lucia', 'cus_lucia', NOW() - INTERVAL '5 months'),
|
|
672
672
|
-- Carlos Finance → Pro Plan (trialing, monthly)
|
|
673
|
-
('sub-blog-carlos', 'team-blog-carlos', 'usr-blog-carlos', '
|
|
673
|
+
('sub-blog-carlos', 'team-blog-carlos', 'usr-blog-carlos', (SELECT id FROM public."plans" WHERE slug = 'pro'), 'trialing',
|
|
674
674
|
NOW(), NOW() + INTERVAL '14 days', 'monthly', NULL, NULL, NULL, NOW() - INTERVAL '4 months')
|
|
675
675
|
ON CONFLICT (id) DO NOTHING;
|
|
676
676
|
|
|
@@ -843,64 +843,64 @@ INSERT INTO public."subscriptions" (
|
|
|
843
843
|
"createdAt", "canceledAt", "cancelAtPeriodEnd", metadata
|
|
844
844
|
) VALUES
|
|
845
845
|
-- 1. Diana Design - Pro Monthly ACTIVE (8 months customer)
|
|
846
|
-
('sub-blog-diana', 'team-blog-diana', 'usr-blog-diana', '
|
|
846
|
+
('sub-blog-diana', 'team-blog-diana', 'usr-blog-diana', (SELECT id FROM public."plans" WHERE slug = 'pro'), 'active',
|
|
847
847
|
NOW() - INTERVAL '25 days', NOW() + INTERVAL '5 days', 'monthly',
|
|
848
848
|
'stripe', 'sub_stripe_diana', 'cus_diana',
|
|
849
849
|
NOW() - INTERVAL '8 months', NULL, false, '{}'::jsonb),
|
|
850
850
|
|
|
851
851
|
-- 2. Elena Eco - Pro Monthly ACTIVE (7 months customer)
|
|
852
|
-
('sub-blog-elena', 'team-blog-elena', 'usr-blog-elena', '
|
|
852
|
+
('sub-blog-elena', 'team-blog-elena', 'usr-blog-elena', (SELECT id FROM public."plans" WHERE slug = 'pro'), 'active',
|
|
853
853
|
NOW() - INTERVAL '20 days', NOW() + INTERVAL '10 days', 'monthly',
|
|
854
854
|
'stripe', 'sub_stripe_elena', 'cus_elena',
|
|
855
855
|
NOW() - INTERVAL '7 months', NULL, false, '{}'::jsonb),
|
|
856
856
|
|
|
857
857
|
-- 3. Felix Fitness - Pro Yearly ACTIVE (14 months = 2nd year)
|
|
858
|
-
('sub-blog-felix', 'team-blog-felix', 'usr-blog-felix', '
|
|
858
|
+
('sub-blog-felix', 'team-blog-felix', 'usr-blog-felix', (SELECT id FROM public."plans" WHERE slug = 'pro'), 'active',
|
|
859
859
|
NOW() - INTERVAL '60 days', NOW() + INTERVAL '305 days', 'yearly',
|
|
860
860
|
'stripe', 'sub_stripe_felix', 'cus_felix',
|
|
861
861
|
NOW() - INTERVAL '14 months', NULL, false, '{}'::jsonb),
|
|
862
862
|
|
|
863
863
|
-- 4. Gloria Gourmet - Pro Yearly ACTIVE (12 months)
|
|
864
|
-
('sub-blog-gloria', 'team-blog-gloria', 'usr-blog-gloria', '
|
|
864
|
+
('sub-blog-gloria', 'team-blog-gloria', 'usr-blog-gloria', (SELECT id FROM public."plans" WHERE slug = 'pro'), 'active',
|
|
865
865
|
NOW() - INTERVAL '30 days', NOW() + INTERVAL '335 days', 'yearly',
|
|
866
866
|
'stripe', 'sub_stripe_gloria', 'cus_gloria',
|
|
867
867
|
NOW() - INTERVAL '12 months', NULL, false, '{}'::jsonb),
|
|
868
868
|
|
|
869
869
|
-- 5. Hugo Health - Pro Monthly ACTIVE (6 months customer)
|
|
870
|
-
('sub-blog-hugo', 'team-blog-hugo', 'usr-blog-hugo', '
|
|
870
|
+
('sub-blog-hugo', 'team-blog-hugo', 'usr-blog-hugo', (SELECT id FROM public."plans" WHERE slug = 'pro'), 'active',
|
|
871
871
|
NOW() - INTERVAL '18 days', NOW() + INTERVAL '12 days', 'monthly',
|
|
872
872
|
'stripe', 'sub_stripe_hugo', 'cus_hugo',
|
|
873
873
|
NOW() - INTERVAL '6 months', NULL, false, '{}'::jsonb),
|
|
874
874
|
|
|
875
875
|
-- 6. Iris Inspire - Pro Monthly PAST_DUE (failed payment)
|
|
876
|
-
('sub-blog-iris', 'team-blog-iris', 'usr-blog-iris', '
|
|
876
|
+
('sub-blog-iris', 'team-blog-iris', 'usr-blog-iris', (SELECT id FROM public."plans" WHERE slug = 'pro'), 'past_due',
|
|
877
877
|
NOW() - INTERVAL '35 days', NOW() - INTERVAL '5 days', 'monthly',
|
|
878
878
|
'stripe', 'sub_stripe_iris', 'cus_iris',
|
|
879
879
|
NOW() - INTERVAL '5 months', NULL, false,
|
|
880
880
|
'{"lastPaymentAttempt": "failed", "retryCount": 2}'::jsonb),
|
|
881
881
|
|
|
882
882
|
-- 7. Jorge Journey - Pro Monthly CANCELED (voluntary - user decided to cancel)
|
|
883
|
-
('sub-blog-jorge', 'team-blog-jorge', 'usr-blog-jorge', '
|
|
883
|
+
('sub-blog-jorge', 'team-blog-jorge', 'usr-blog-jorge', (SELECT id FROM public."plans" WHERE slug = 'pro'), 'canceled',
|
|
884
884
|
NOW() - INTERVAL '45 days', NOW() - INTERVAL '15 days', 'monthly',
|
|
885
885
|
'stripe', 'sub_stripe_jorge', 'cus_jorge',
|
|
886
886
|
NOW() - INTERVAL '9 months', NOW() - INTERVAL '15 days', false,
|
|
887
887
|
'{"cancelReason": "voluntary", "canceledBy": "user", "feedback": "taking a break"}'::jsonb),
|
|
888
888
|
|
|
889
889
|
-- 8. Karen Kitchen - Pro Yearly CANCELED (payment failed 4 times)
|
|
890
|
-
('sub-blog-karen', 'team-blog-karen', 'usr-blog-karen', '
|
|
890
|
+
('sub-blog-karen', 'team-blog-karen', 'usr-blog-karen', (SELECT id FROM public."plans" WHERE slug = 'pro'), 'canceled',
|
|
891
891
|
NOW() - INTERVAL '400 days', NOW() - INTERVAL '35 days', 'yearly',
|
|
892
892
|
'stripe', 'sub_stripe_karen', 'cus_karen',
|
|
893
893
|
NOW() - INTERVAL '15 months', NOW() - INTERVAL '35 days', false,
|
|
894
894
|
'{"cancelReason": "payment_failed", "failedAttempts": 4, "lastCardEnding": "4242"}'::jsonb),
|
|
895
895
|
|
|
896
896
|
-- 9. Leo Learn - Free Plan ACTIVE (no billing)
|
|
897
|
-
('sub-blog-leo', 'team-blog-leo', 'usr-blog-leo', '
|
|
897
|
+
('sub-blog-leo', 'team-blog-leo', 'usr-blog-leo', (SELECT id FROM public."plans" WHERE slug = 'free'), 'active',
|
|
898
898
|
NOW() - INTERVAL '2 months', NOW() + INTERVAL '100 years', 'monthly',
|
|
899
899
|
NULL, NULL, NULL,
|
|
900
900
|
NOW() - INTERVAL '2 months', NULL, false, '{}'::jsonb),
|
|
901
901
|
|
|
902
902
|
-- 10. Maria Music - Pro Monthly TRIALING (5 days in)
|
|
903
|
-
('sub-blog-maria', 'team-blog-maria', 'usr-blog-maria', '
|
|
903
|
+
('sub-blog-maria', 'team-blog-maria', 'usr-blog-maria', (SELECT id FROM public."plans" WHERE slug = 'pro'), 'trialing',
|
|
904
904
|
NOW() - INTERVAL '5 days', NOW() + INTERVAL '25 days', 'monthly',
|
|
905
905
|
NULL, NULL, NULL,
|
|
906
906
|
NOW() - INTERVAL '5 days', NULL, false, '{}'::jsonb)
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,22 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nextsparkjs/theme-blog",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.190",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./config/theme.config.ts",
|
|
7
7
|
"requiredPlugins": [],
|
|
8
|
-
"dependencies": {
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"@tailwindcss/container-queries": "^0.1.1",
|
|
10
|
+
"dompurify": "^3.2.7",
|
|
11
|
+
"next-themes": "^0.4.6"
|
|
12
|
+
},
|
|
9
13
|
"peerDependencies": {
|
|
14
|
+
"@nextsparkjs/core": ">=0.1.0-0",
|
|
15
|
+
"@nextsparkjs/testing": ">=0.1.0-0",
|
|
10
16
|
"lucide-react": "^0.539.0",
|
|
11
|
-
"next": "^15.0.0",
|
|
17
|
+
"next": "^15.0.0 || ^16.0.0",
|
|
12
18
|
"next-intl": "^4.0.0",
|
|
13
19
|
"react": "^19.0.0",
|
|
14
20
|
"react-dom": "^19.0.0",
|
|
15
|
-
"zod": "^4.0.0"
|
|
16
|
-
"@nextsparkjs/core": "0.1.0-beta.19"
|
|
21
|
+
"zod": "^4.0.0"
|
|
17
22
|
},
|
|
18
23
|
"nextspark": {
|
|
19
24
|
"type": "theme",
|
|
20
25
|
"name": "blog"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/dompurify": "^3.2.0"
|
|
21
29
|
}
|
|
22
30
|
}
|
package/styles/globals.css
CHANGED
|
@@ -3,8 +3,54 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Warm amber/orange palette with editorial typography.
|
|
5
5
|
* Fonts: Oxanium (sans), Merriweather (serif), Fira Code (mono)
|
|
6
|
+
*
|
|
7
|
+
* ÚNICA FUENTE DE VERDAD para estilos del theme.
|
|
8
|
+
* Editar este archivo para customizar el design system.
|
|
6
9
|
*/
|
|
7
10
|
|
|
11
|
+
/* =============================================
|
|
12
|
+
IMPORTS
|
|
13
|
+
============================================= */
|
|
14
|
+
@import "tailwindcss";
|
|
15
|
+
|
|
16
|
+
/* =============================================
|
|
17
|
+
TAILWIND v4 DARK MODE CONFIGURATION
|
|
18
|
+
|
|
19
|
+
Por defecto Tailwind v4 usa @media (prefers-color-scheme: dark)
|
|
20
|
+
para las variantes dark:. Esto causa problemas porque:
|
|
21
|
+
- next-themes usa la clase .dark en el HTML
|
|
22
|
+
- El sistema operativo puede tener dark mode aunque la app use light
|
|
23
|
+
|
|
24
|
+
Esta configuración hace que Tailwind use el selector de clase .dark
|
|
25
|
+
en lugar de la media query, sincronizándose con next-themes.
|
|
26
|
+
============================================= */
|
|
27
|
+
@custom-variant dark (&:where(.dark, .dark *));
|
|
28
|
+
|
|
29
|
+
@plugin "@tailwindcss/container-queries";
|
|
30
|
+
/* @nextsparkjs/core/styles/ui.css is intentionally NOT imported here: it is
|
|
31
|
+
pre-compiled by packages/core/scripts/build/build-ui-css.mjs with a plain
|
|
32
|
+
`@import "tailwindcss"` that has no knowledge of this theme's
|
|
33
|
+
`@custom-variant dark` override above, so it bakes every dark: utility it
|
|
34
|
+
contains (including ThemeToggle's own dark:-rotate-90/scale-0 icon
|
|
35
|
+
classes) using Tailwind's default `@media (prefers-color-scheme: dark)`
|
|
36
|
+
strategy. Importing that already-compiled CSS re-declares the same
|
|
37
|
+
classes with that conflicting strategy, so they stop following
|
|
38
|
+
next-themes' `.dark`/`.light` class and instead silently track the
|
|
39
|
+
visitor's OS/browser color-scheme preference (issue #110). The @source
|
|
40
|
+
globs below already scan core's source (monorepo) and its compiled dist
|
|
41
|
+
output (npm) directly into THIS compilation, so they pick up the same
|
|
42
|
+
classes correctly scoped to `@custom-variant dark` — the separate ui.css
|
|
43
|
+
import is redundant. */
|
|
44
|
+
@import "@nextsparkjs/core/styles/utilities.css";
|
|
45
|
+
@import "@nextsparkjs/core/styles/docs.css";
|
|
46
|
+
@source "../../../**/*.{js,ts,jsx,tsx}";
|
|
47
|
+
/* Core package: monorepo (short path) + npm projects (long path) */
|
|
48
|
+
@source "../node_modules/@nextsparkjs/core/dist/**/*.js";
|
|
49
|
+
@source "../../../../node_modules/@nextsparkjs/core/dist/**/*.js";
|
|
50
|
+
|
|
51
|
+
/* =============================================
|
|
52
|
+
BLOG THEME - LIGHT MODE
|
|
53
|
+
============================================= */
|
|
8
54
|
:root {
|
|
9
55
|
--background: oklch(0.9885 0.0057 84.5659);
|
|
10
56
|
--foreground: oklch(0.3660 0.0251 49.6085);
|
|
@@ -152,6 +198,18 @@
|
|
|
152
198
|
--shadow-2xl: var(--shadow-2xl);
|
|
153
199
|
}
|
|
154
200
|
|
|
201
|
+
/* =============================================
|
|
202
|
+
BASE STYLES
|
|
203
|
+
============================================= */
|
|
204
|
+
* {
|
|
205
|
+
border-color: var(--border);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
body {
|
|
209
|
+
background-color: var(--background);
|
|
210
|
+
color: var(--foreground);
|
|
211
|
+
}
|
|
212
|
+
|
|
155
213
|
/* ============================================================================
|
|
156
214
|
TYPOGRAPHY
|
|
157
215
|
============================================================================ */
|
|
@@ -13,6 +13,7 @@ import Image from 'next/image'
|
|
|
13
13
|
import { Twitter, Linkedin, Globe, User } from 'lucide-react'
|
|
14
14
|
import { PostCard } from '@/themes/blog/components/public/PostCard'
|
|
15
15
|
import { cn } from '@nextsparkjs/core/lib/utils'
|
|
16
|
+
import { withBasePath, withBasePathIfInApp } from '@nextsparkjs/core/lib/base-path'
|
|
16
17
|
|
|
17
18
|
interface Author {
|
|
18
19
|
id: string
|
|
@@ -60,7 +61,7 @@ export default function AuthorPage({ params }: PageProps) {
|
|
|
60
61
|
setLoading(true)
|
|
61
62
|
setError(null)
|
|
62
63
|
|
|
63
|
-
const response = await fetch(`/api/v1/theme/blog/authors/${username}
|
|
64
|
+
const response = await fetch(withBasePath(`/api/v1/theme/blog/authors/${username}`), {
|
|
64
65
|
method: 'GET',
|
|
65
66
|
headers: {
|
|
66
67
|
'Content-Type': 'application/json',
|
|
@@ -137,7 +138,7 @@ export default function AuthorPage({ params }: PageProps) {
|
|
|
137
138
|
<div className="flex-shrink-0">
|
|
138
139
|
{author.image ? (
|
|
139
140
|
<Image
|
|
140
|
-
src={author.image}
|
|
141
|
+
src={withBasePathIfInApp(author.image)}
|
|
141
142
|
alt={author.name}
|
|
142
143
|
width={128}
|
|
143
144
|
height={128}
|
|
@@ -165,7 +166,7 @@ export default function AuthorPage({ params }: PageProps) {
|
|
|
165
166
|
<div className="flex items-center gap-4">
|
|
166
167
|
{author.socialTwitter && (
|
|
167
168
|
<a
|
|
168
|
-
href={author.socialTwitter}
|
|
169
|
+
href={withBasePathIfInApp(author.socialTwitter)}
|
|
169
170
|
target="_blank"
|
|
170
171
|
rel="noopener noreferrer"
|
|
171
172
|
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
@@ -176,7 +177,7 @@ export default function AuthorPage({ params }: PageProps) {
|
|
|
176
177
|
)}
|
|
177
178
|
{author.socialLinkedin && (
|
|
178
179
|
<a
|
|
179
|
-
href={author.socialLinkedin}
|
|
180
|
+
href={withBasePathIfInApp(author.socialLinkedin)}
|
|
180
181
|
target="_blank"
|
|
181
182
|
rel="noopener noreferrer"
|
|
182
183
|
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
@@ -187,7 +188,7 @@ export default function AuthorPage({ params }: PageProps) {
|
|
|
187
188
|
)}
|
|
188
189
|
{author.socialWebsite && (
|
|
189
190
|
<a
|
|
190
|
-
href={author.socialWebsite}
|
|
191
|
+
href={withBasePathIfInApp(author.socialWebsite)}
|
|
191
192
|
target="_blank"
|
|
192
193
|
rel="noopener noreferrer"
|
|
193
194
|
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
@@ -10,6 +10,7 @@ import { useState, useEffect } from 'react'
|
|
|
10
10
|
import Link from 'next/link'
|
|
11
11
|
import Image from 'next/image'
|
|
12
12
|
import { User } from 'lucide-react'
|
|
13
|
+
import { withBasePath, withBasePathIfInApp } from '@nextsparkjs/core/lib/base-path'
|
|
13
14
|
|
|
14
15
|
interface Author {
|
|
15
16
|
id: string
|
|
@@ -31,7 +32,7 @@ export default function AuthorsPage() {
|
|
|
31
32
|
setLoading(true)
|
|
32
33
|
setError(null)
|
|
33
34
|
|
|
34
|
-
const response = await fetch('/api/v1/theme/blog/authors', {
|
|
35
|
+
const response = await fetch(withBasePath('/api/v1/theme/blog/authors'), {
|
|
35
36
|
method: 'GET',
|
|
36
37
|
headers: {
|
|
37
38
|
'Content-Type': 'application/json',
|
|
@@ -115,7 +116,7 @@ export default function AuthorsPage() {
|
|
|
115
116
|
<div className="flex justify-center mb-4">
|
|
116
117
|
{author.image ? (
|
|
117
118
|
<Image
|
|
118
|
-
src={author.image}
|
|
119
|
+
src={withBasePathIfInApp(author.image)}
|
|
119
120
|
alt={author.name}
|
|
120
121
|
width={80}
|
|
121
122
|
height={80}
|
|
@@ -14,6 +14,7 @@ import { useTranslations } from 'next-intl'
|
|
|
14
14
|
import { PostCard } from '@/themes/blog/components/public/PostCard'
|
|
15
15
|
import { cn } from '@nextsparkjs/core/lib/utils'
|
|
16
16
|
import { Loader2 } from 'lucide-react'
|
|
17
|
+
import { withBasePath } from '@nextsparkjs/core/lib/base-path'
|
|
17
18
|
|
|
18
19
|
interface Post {
|
|
19
20
|
id: string
|
|
@@ -65,7 +66,7 @@ export default function BlogHomePage() {
|
|
|
65
66
|
|
|
66
67
|
// Fetch published posts from ALL authors via public endpoint
|
|
67
68
|
// This endpoint does not require authentication and aggregates posts cross-team
|
|
68
|
-
const response = await fetch('/api/v1/theme/blog/posts/public?limit=20', {
|
|
69
|
+
const response = await fetch(withBasePath('/api/v1/theme/blog/posts/public?limit=20'), {
|
|
69
70
|
method: 'GET',
|
|
70
71
|
headers: {
|
|
71
72
|
'Content-Type': 'application/json',
|
|
@@ -12,10 +12,12 @@ import Link from 'next/link'
|
|
|
12
12
|
import Image from 'next/image'
|
|
13
13
|
import { ArrowLeft, Calendar, Clock, Loader2, Share2, Bookmark } from 'lucide-react'
|
|
14
14
|
import { use, useEffect, useState } from 'react'
|
|
15
|
+
import { sanitizePostHtml } from '@/themes/blog/lib/sanitize-post-html'
|
|
15
16
|
import { ReadingProgress } from '@/themes/blog/components/public/ReadingProgress'
|
|
16
17
|
import { AuthorBio } from '@/themes/blog/components/public/AuthorBio'
|
|
17
18
|
import { RelatedPosts } from '@/themes/blog/components/public/RelatedPosts'
|
|
18
19
|
import { Button } from '@nextsparkjs/core/components/ui/button'
|
|
20
|
+
import { withBasePath, withBasePathInHtml, withBasePathIfInApp } from '@nextsparkjs/core/lib/base-path'
|
|
19
21
|
|
|
20
22
|
interface Post {
|
|
21
23
|
id: string
|
|
@@ -57,7 +59,7 @@ async function fetchPost(identifier: string): Promise<Post | null> {
|
|
|
57
59
|
try {
|
|
58
60
|
const headers = buildHeaders()
|
|
59
61
|
|
|
60
|
-
const byIdResponse = await fetch(`/api/v1/posts?ids=${encodeURIComponent(identifier)}&status=published&limit=1
|
|
62
|
+
const byIdResponse = await fetch(withBasePath(`/api/v1/posts?ids=${encodeURIComponent(identifier)}&status=published&limit=1`), {
|
|
61
63
|
credentials: 'include',
|
|
62
64
|
headers,
|
|
63
65
|
})
|
|
@@ -69,7 +71,7 @@ async function fetchPost(identifier: string): Promise<Post | null> {
|
|
|
69
71
|
}
|
|
70
72
|
}
|
|
71
73
|
|
|
72
|
-
const bySlugResponse = await fetch(`/api/v1/posts?slug=${encodeURIComponent(identifier)}&status=published&limit=1
|
|
74
|
+
const bySlugResponse = await fetch(withBasePath(`/api/v1/posts?slug=${encodeURIComponent(identifier)}&status=published&limit=1`), {
|
|
73
75
|
credentials: 'include',
|
|
74
76
|
headers,
|
|
75
77
|
})
|
|
@@ -145,7 +147,7 @@ async function fetchRelatedPosts(currentPostId: string): Promise<RelatedPost[]>
|
|
|
145
147
|
const headers = buildHeaders()
|
|
146
148
|
|
|
147
149
|
// Fetch latest published posts
|
|
148
|
-
const response = await fetch('/api/v1/posts?status=published&limit=4&sortBy=publishedAt&sortOrder=desc', {
|
|
150
|
+
const response = await fetch(withBasePath('/api/v1/posts?status=published&limit=4&sortBy=publishedAt&sortOrder=desc'), {
|
|
149
151
|
credentials: 'include',
|
|
150
152
|
headers,
|
|
151
153
|
})
|
|
@@ -281,7 +283,7 @@ export default function PostPage({ params }: PageProps) {
|
|
|
281
283
|
<figure className="mb-12">
|
|
282
284
|
<div className="aspect-[21/9] relative overflow-hidden rounded-xl">
|
|
283
285
|
<Image
|
|
284
|
-
src={post.featuredImage}
|
|
286
|
+
src={withBasePathIfInApp(post.featuredImage)}
|
|
285
287
|
alt={post.title}
|
|
286
288
|
fill
|
|
287
289
|
className="object-cover"
|
|
@@ -294,7 +296,7 @@ export default function PostPage({ params }: PageProps) {
|
|
|
294
296
|
|
|
295
297
|
{/* Article Content */}
|
|
296
298
|
<div className="article-content">
|
|
297
|
-
<div dangerouslySetInnerHTML={{ __html: renderContent(post.content) }} />
|
|
299
|
+
<div dangerouslySetInnerHTML={{ __html: withBasePathInHtml(sanitizePostHtml(renderContent(post.content))) }} />
|
|
298
300
|
</div>
|
|
299
301
|
|
|
300
302
|
{/* Divider */}
|