Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 0 additions & 33 deletions .env

This file was deleted.

8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
MAILCHIMP_SUBSCRIBE_ENDPOINT=
MAILCHIMP_B_FIELD=

# visit https://giscus.app to get your Giscus ids
NEXT_PUBLIC_GISCUS_REPO=
NEXT_PUBLIC_GISCUS_REPOSITORY_ID=
NEXT_PUBLIC_GISCUS_CATEGORY=
NEXT_PUBLIC_GISCUS_CATEGORY_ID=
2 changes: 2 additions & 0 deletions .github/workflows/firebase-hosting-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ jobs:
env:
EXPORT: 1
UNOPTIMIZED: 1
MAILCHIMP_SUBSCRIBE_ENDPOINT: ${{ vars.MAILCHIMP_SUBSCRIBE_ENDPOINT }}
MAILCHIMP_B_FIELD: ${{ vars.MAILCHIMP_B_FIELD }}
NEXT_PUBLIC_GISCUS_REPO: ${{ vars.NEXT_PUBLIC_GISCUS_REPO }}
NEXT_PUBLIC_GISCUS_REPOSITORY_ID: ${{ vars.NEXT_PUBLIC_GISCUS_REPOSITORY_ID }}
NEXT_PUBLIC_GISCUS_CATEGORY: ${{ vars.NEXT_PUBLIC_GISCUS_CATEGORY }}
Expand Down
7 changes: 6 additions & 1 deletion .github/workflows/firebase-hosting-pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- run: npm ci
- name: Build
run: npm run build
env:
MAILCHIMP_SUBSCRIBE_ENDPOINT: ${{ vars.MAILCHIMP_SUBSCRIBE_ENDPOINT }}
MAILCHIMP_B_FIELD: ${{ vars.MAILCHIMP_B_FIELD }}
- uses: FirebaseExtended/action-hosting-deploy@v0
with:
repoToken: ${{ secrets.GITHUB_TOKEN }}
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ yarn-debug.log*
yarn-error.log*

# local env files
.env
.env.local
.env.development.local
.env.test.local
Expand Down
14 changes: 12 additions & 2 deletions app/Main.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import Link from '@/components/Link'
import NewsletterForm from '@/components/NewsletterForm'
import Tag from '@/components/Tag'
import siteMetadata from '@/data/siteMetadata'
import { formatDate } from 'pliny/utils/formatDate'
import NewsletterForm from 'pliny/ui/NewsletterForm'

const MAX_DISPLAY = 5

export default function Home({ posts }) {
const newsletterConfig = (siteMetadata.newsletter || {}) as {
externalUrl?: string
emailFieldName?: string
hiddenFieldName?: string
}

return (
<>
<div className="divide-y divide-gray-200 dark:divide-gray-700">
Expand Down Expand Up @@ -83,7 +89,11 @@ export default function Home({ posts }) {
)}
{siteMetadata.newsletter?.provider && (
<div className="flex items-center justify-center pt-4">
<NewsletterForm />
<NewsletterForm
action={newsletterConfig.externalUrl || ''}
emailFieldName={newsletterConfig.emailFieldName || 'EMAIL'}
hiddenFieldName={newsletterConfig.hiddenFieldName || ''}
/>
</div>
)}
</>
Expand Down
88 changes: 80 additions & 8 deletions app/api/newsletter/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,83 @@
import { NewsletterAPI } from 'pliny/newsletter'
import siteMetadata from '@/data/siteMetadata'
import { NextResponse } from 'next/server'

export const dynamic = 'force-static'
export const dynamic = 'force-dynamic'

const handler = NewsletterAPI({
// @ts-ignore
provider: siteMetadata.newsletter.provider,
})
const stripHtml = (value: string) =>
value
.replace(/<[^>]*>/g, '')
.replace(/^\d+\s*-\s*/, '')
.trim()

export { handler as GET, handler as POST }
const parseJsonpPayload = (raw: string) => {
const start = raw.indexOf('(')
const end = raw.lastIndexOf(')')
if (start === -1 || end === -1 || end <= start + 1) {
return null
}
const json = raw.slice(start + 1, end)
try {
return JSON.parse(json) as { result?: string; msg?: string }
} catch {
return null
}
}

export async function POST(request: Request) {
try {
const body = (await request.json()) as {
email?: string
emailFieldName?: string
hiddenFieldName?: string
action?: string
}
const email = (body.email || '').trim()
if (!email) {
return NextResponse.json({ error: 'Email is required.' }, { status: 400 })
}

const endpoint = (body.action || process.env.MAILCHIMP_SUBSCRIBE_ENDPOINT || '').trim()
if (!endpoint) {
return NextResponse.json(
{ error: 'Newsletter endpoint is not configured.' },
{ status: 500 }
)
}

const normalizedEndpoint = endpoint.includes('/post-json')
? endpoint
: endpoint.replace(/\/post(\?|$)/, '/post-json$1')
const emailFieldName = (body.emailFieldName || 'EMAIL').trim()
const hiddenFieldName = (body.hiddenFieldName || process.env.MAILCHIMP_B_FIELD || '').trim()

const url = new URL(normalizedEndpoint)
url.searchParams.set(emailFieldName, email)
if (hiddenFieldName) {
url.searchParams.set(hiddenFieldName, '')
}
url.searchParams.set('c', 'mailchimpCallback')

const mailchimpResponse = await fetch(url.toString(), { cache: 'no-store' })
const responseText = await mailchimpResponse.text()
const payload = parseJsonpPayload(responseText)

if (!payload) {
return NextResponse.json(
{ error: 'Could not parse newsletter provider response.' },
{ status: 502 }
)
}

if (payload.result !== 'success') {
return NextResponse.json(
{ error: stripHtml(payload.msg || 'Subscription failed. Please try again.') },
{ status: 400 }
)
}

return NextResponse.json({
message: 'Thanks! Please check your inbox to confirm your subscription.',
})
} catch {
return NextResponse.json({ error: 'Could not submit right now. Please try again.' }, { status: 500 })
}
}
16 changes: 11 additions & 5 deletions app/tag-data.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,18 @@
"rest": 1,
"web-development": 3,
"web-services": 1,
"frontend": 2,
"javascript": 1,
"software-development": 3,
"css": 1,
"javascript": 2,
"node": 1,
"nodejs": 1,
"npm": 1,
"operating-system": 1,
"package-manager": 1,
"runtime-environment": 1,
"android": 1,
"androidx": 1,
"jetifier": 1,
"mobile-development": 1
"mobile-development": 1,
"software-development": 3,
"css": 1,
"frontend": 2
}
18 changes: 16 additions & 2 deletions components/MDXComponents.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,30 @@
import TOCInline from 'pliny/ui/TOCInline'
import Pre from 'pliny/ui/Pre'
import BlogNewsletterForm from 'pliny/ui/BlogNewsletterForm'
import type { MDXComponents } from 'mdx/types'
import siteMetadata from '@/data/siteMetadata'
import Image from './Image'
import CustomLink from './Link'
import NewsletterForm from './NewsletterForm'
import TableWrapper from './TableWrapper'

const newsletterConfig = (siteMetadata.newsletter || {}) as {
externalUrl?: string
emailFieldName?: string
hiddenFieldName?: string
}

export const components: MDXComponents = {
Image,
TOCInline,
a: CustomLink,
pre: Pre,
table: TableWrapper,
BlogNewsletterForm,
BlogNewsletterForm: (props) => (
<NewsletterForm
{...props}
action={newsletterConfig.externalUrl || ''}
emailFieldName={newsletterConfig.emailFieldName || 'EMAIL'}
hiddenFieldName={newsletterConfig.hiddenFieldName || ''}
/>
),
}
103 changes: 103 additions & 0 deletions components/NewsletterForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
'use client'

import { FormEvent, useState } from 'react'

type NewsletterFormProps = {
title?: string
action?: string
emailFieldName?: string
hiddenFieldName?: string
}

export default function NewsletterForm({
title = 'Subscribe to the newsletter',
action = '',
emailFieldName = 'EMAIL',
hiddenFieldName = '',
}: NewsletterFormProps) {
const [email, setEmail] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [statusMessage, setStatusMessage] = useState('')
const [statusType, setStatusType] = useState<'idle' | 'success' | 'error'>('idle')
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (!email) return

setIsSubmitting(true)
setStatusType('idle')
setStatusMessage('')
try {
const response = await fetch('/api/newsletter', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
emailFieldName,
hiddenFieldName,
action,
}),
})
const data = (await response.json()) as { error?: string; message?: string }
if (!response.ok) {
setStatusType('error')
setStatusMessage(data.error || 'Subscription failed. Please try again.')
return
}
setStatusType('success')
setStatusMessage(data.message || 'Thanks! Please check your inbox to confirm your subscription.')
setEmail('')
} catch {
setStatusType('error')
setStatusMessage('Could not submit right now. Please try again.')
} finally {
setIsSubmitting(false)
}
}

return (
<div className="w-full max-w-md">
<h3 className="text-xl font-semibold tracking-tight text-gray-900 dark:text-gray-100">
{title}
</h3>
<form
onSubmit={handleSubmit}
className="mt-4 flex flex-col gap-3 sm:flex-row"
>
<label htmlFor="newsletter-email" className="sr-only">
Email address
</label>
<input
id="newsletter-email"
name={emailFieldName}
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
autoComplete="email"
placeholder="you@example.com"
className="ring-primary-500 w-full rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 transition outline-none placeholder:text-gray-400 focus:ring-2 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
/>
<button
type="submit"
disabled={isSubmitting}
className="bg-primary-500 hover:bg-primary-600 focus:ring-primary-500 rounded-md px-4 py-2 font-medium text-white transition focus:ring-2 focus:ring-offset-2 focus:outline-none dark:focus:ring-offset-gray-900"
>
{isSubmitting ? 'Submitting...' : 'Sign up'}
</button>
</form>
{statusType !== 'idle' ? (
<p
className={`mt-3 text-sm ${
statusType === 'success'
? 'text-green-700 dark:text-green-400'
: 'text-red-700 dark:text-red-400'
}`}
>
{statusMessage}
</p>
) : null}
</div>
)
}
Loading
Loading