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
4 changes: 4 additions & 0 deletions with-remix/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.react-router
build
node_modules
README.md
13 changes: 13 additions & 0 deletions with-remix/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@

# https://docs.polar.sh/integrate/oat
POLAR_ACCESS_TOKEN=

# https://docs.polar.sh/integrate/webhooks/endpoints#setup-webhooks
POLAR_WEBHOOK_SECRET=

# URL to redirect to after successful order
POLAR_SUCCESS_URL=

# Polar server mode (production or sandbox)
POLAR_MODE=sandbox

7 changes: 7 additions & 0 deletions with-remix/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.DS_Store
.env
/node_modules/

# React Router
/.react-router/
/build/
3 changes: 3 additions & 0 deletions with-remix/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
.env
README.md
5 changes: 5 additions & 0 deletions with-remix/.prettierrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"semi": false,
"printWidth": 180,
"singleQuote": true
}
22 changes: 22 additions & 0 deletions with-remix/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
FROM node:20-alpine AS development-dependencies-env
COPY . /app
WORKDIR /app
RUN npm ci

FROM node:20-alpine AS production-dependencies-env
COPY ./package.json package-lock.json /app/
WORKDIR /app
RUN npm ci --omit=dev

FROM node:20-alpine AS build-env
COPY . /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN npm run build

FROM node:20-alpine
COPY ./package.json package-lock.json /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["npm", "run", "start"]
104 changes: 104 additions & 0 deletions with-remix/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
![](../logo.svg)

# Getting Started with Polar and Remix

This repo is a demonstration of the integration of Polar features such as Webhooks, Customer Portal and Checkout creation organization in Remix.

## Prerequisites

- Node.js installed on your system
- Your POLAR_ACCESS_TOKEN, POLAR_WEBHOOK_SECRET and POLAR_MODE
> this is an optional configuration, adjust based on your needs



## 1. Clone the repository

```bash
npx degit polarsource/examples/with-remix ./with-remix
```

## 2. Install dependencies:

```bash
npm install
```

## 3. Configure environment variables:

Create a `.env` file in the project root with your Polar credentials:

```bash
cp .env.example .env
```

Add your Polar API credentials to the `.env` file:

```env
POLAR_ACCESS_TOKEN=

POLAR_WEBHOOK_SECRET=

POLAR_SUCCESS_URL=

POLAR_MODE=
```

You can find your POLAR_ACCESS_TOKEN and POLAR_WEBHOOK_SECRET variables in your Polar dashboard settings. see `.env.example`

## 4. Start Development Server

```bash
npm run dev
```

Visit `http://localhost:5173` to see the demo interface.

## Configuration

### Polar Dashboard Setup

1. **Create Products**: Set up products in your Polar dashboard
2. **Configure Webhooks**: Add webhook endpoint `https://your-domain.com/api/webhooks/polar`
3. **Get Credentials**: Copy your access token and webhook secret

## Deployment

### Vercel (Recommended)

1. Connect your repository to Vercel
1. Add environment variables in Vercel dashboard
1. Deploy automatically on push

[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/polarsource/examples/tree/main/with-remix&env=POLAR_ACCESS_TOKEN,POLAR_WEBHOOK_SECRET,POLAR_SUCCESS_URL,POLAR_MODE&envDescription=Configure%20your%20Polar%20API%20credentials%20and%20mode.&envLink=https://docs.polar.sh/integrate/webhooks/endpoints#setup-webhooks)

### Other Platforms

The project works with any platform that supports Remix:


- Cloudflare
- Netlify
- Node etc.

## 5. Testing

### Local Testing

1. Use Polar's sandbox environment
2. Test with sandbox product IDs
3. Monitor webhook events payloads in your console using ngrok.
4. Use `npm run scope` to get POLAR_MODE in your .env

```bash
npm run scope
```

### Webhook Testing

1. Use tools like ngrok for local webhook testing
2. Configure webhook URL in Polar dashboard
3. Configure `vite.server.allowedhosts` in `vite.config.ts` to allow it.
4. Trigger test events from Polar dashboard


14 changes: 14 additions & 0 deletions with-remix/app/app.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
@import 'tailwindcss';

@theme {
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
}

html,
body {
@apply bg-white dark:bg-gray-950;

@media (prefers-color-scheme: dark) {
color-scheme: dark;
}
}
37 changes: 37 additions & 0 deletions with-remix/app/components/ProductCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Link } from 'react-router'

type ProductCardProps = {
id: string
name: string
description?: string | null
image?: string | null
priceAmount?: number
priceCurrency?: string
}

export function ProductCard(props: ProductCardProps) {
const formattedPrice = props.priceAmount
? new Intl.NumberFormat('en-US', {
style: 'currency',
currency: props.priceCurrency || 'USD',
}).format(props.priceAmount / 100)
: '—'

const checkoutHref = `/polar/checkout?products=${encodeURIComponent(props.id)}`

return (
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 overflow-hidden rounded-lg hover:shadow-lg transition-shadow">
<img src={props.image || '/placeholder.svg'} alt={props.name} className="w-full h-48 object-cover" />
<div className="p-6">
<h3 className="text-xl font-semibold mb-2">{props.name}</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">{props.description}</p>
<div className="flex items-center justify-between">
<span className="text-2xl font-medium">{formattedPrice}</span>
<Link to={checkoutHref} className="px-4 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors">
Buy Now
</Link>
</div>
</div>
</div>
)
}
46 changes: 46 additions & 0 deletions with-remix/app/components/ProductsGrid.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { ProductCard } from '~/components/ProductCard'

type Product = {
id: string
name: string
description?: string | null
medias?: { publicUrl: string }[]
prices?: { priceAmount?: number; priceCurrency?: string }[]
}

export function ProductsGrid({ products }: { products: Product[] }) {
if (!products || products.length === 0) {
return (
<div className="text-center">
<p className="text-gray-500">No products available.</p>
</div>
)
}

return (
<section id="products" className="py-16">
<div className="container mx-auto px-4">
<h2 className="text-3xl font-medium text-center mb-12">Products</h2>

<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{products.map((product) => {
const firstMedia = product.medias?.[0]
const firstPrice = product.prices?.[0]

return (
<ProductCard
key={product.id}
id={product.id}
name={product.name}
description={product.description}
image={firstMedia?.publicUrl ?? null}
priceAmount={firstPrice?.priceAmount ?? undefined}
priceCurrency={firstPrice?.priceCurrency ?? 'USD'}
/>
)
})}
</div>
</div>
</section>
)
}
16 changes: 16 additions & 0 deletions with-remix/app/polar.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Polar } from '@polar-sh/sdk'
import 'dotenv/config'

export const getPolarClient = () => {
const accessToken = process.env.POLAR_ACCESS_TOKEN
const server = process.env.POLAR_MODE as 'production' | 'sandbox' | undefined

if (!accessToken) {
throw new Error('Missing POLAR_ACCESS_TOKEN environment variable')
}

return new Polar({
accessToken,
server,
})
}
106 changes: 106 additions & 0 deletions with-remix/app/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@

import {
isRouteErrorResponse,
Links,
Meta,
NavLink,
Outlet,
Scripts,
ScrollRestoration,
} from 'react-router'

import type { Route } from './+types/root'
import './app.css'

export const links: Route.LinksFunction = () => [
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
{
rel: 'preconnect',
href: 'https://fonts.gstatic.com',
crossOrigin: 'anonymous',
},
{
rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap',
},
]

export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{/* Header ported from Nuxt layout */}
<header className="border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 sticky top-0 z-10">
<div className="container mx-auto px-4 py-4">
<nav className="flex items-center justify-between">
<NavLink to="/" className="text-2xl font-bold text-blue-700 dark:text-blue-500">
Polar with Remix
</NavLink>
<div className="flex items-center gap-4">
<NavLink
to="/"
className={({ isActive }) =>
`p-3 rounded-md transition-colors ${isActive ? 'text-blue-700 dark:text-blue-400 bg-blue-50 dark:bg-gray-800' : 'text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-white'
}`
}
>
Products
</NavLink>
<NavLink
to="/polar/customer-portal"
className={({ isActive }) =>
`p-3 rounded-md transition-colors ${isActive ? 'text-blue-700 dark:text-blue-400 bg-blue-50 dark:bg-gray-800' : 'text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-white'
}`
}
>
Customer Portal
</NavLink>
</div>
</nav>
</div>
</header>

<main className="min-h-screen">{children}</main>

<ScrollRestoration />
<Scripts />
</body>
</html>
)
}

export default function App() {
return <Outlet />
}

export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = 'Oops!'
let details = 'An unexpected error occurred.'
let stack: string | undefined

if (isRouteErrorResponse(error)) {
message = error.status === 404 ? '404' : 'Error'
details = error.status === 404 ? 'The requested page could not be found.' : error.statusText || details
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message
stack = error.stack
}

return (
<main className="pt-16 p-4 container mx-auto text-center">
<h1 className="text-4xl font-bold text-red-500">{message}</h1>
<p className="text-xl mt-4">{details}</p>
{stack && (
<pre className="mt-8 p-4 bg-gray-100 dark:bg-gray-800 rounded-md overflow-x-auto text-left">
<code>{stack}</code>
</pre>
)}
</main>
)
}
Loading