Template
Vite + Hono + TanStack port of the Shopify storefront
Ports nextjs-templates/shopify-storefront to Vite, TanStack Router/Query, Tailwind v4 and Hono, without the AI store assistant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
// Classic Storefront customer accounts. The access token only ever lives in
|
||||
// the httpOnly session cookie; none of these routes return it.
|
||||
import { Hono } from 'hono';
|
||||
import {
|
||||
activateAccount,
|
||||
createCustomer,
|
||||
customerErrorMessage,
|
||||
getCustomer,
|
||||
login,
|
||||
logout,
|
||||
recoverPassword,
|
||||
resetPassword,
|
||||
toCustomerGid,
|
||||
} from '@/services/shopify/customer';
|
||||
import {
|
||||
clearSessionToken,
|
||||
getSessionToken,
|
||||
setSessionToken,
|
||||
} from './session';
|
||||
|
||||
type Body = Record<string, string | undefined>;
|
||||
|
||||
// A malformed body reads as empty, so each route's own validation answers it.
|
||||
const readBody = (req: Request): Promise<Body> =>
|
||||
req.json().catch(() => ({}));
|
||||
|
||||
export const account = new Hono();
|
||||
|
||||
// Minimal session probe for the header menu.
|
||||
account.get('/me', async (c) => {
|
||||
const token = getSessionToken(c);
|
||||
if (!token) return c.json({ customer: null });
|
||||
|
||||
const customer = await getCustomer(token, 0);
|
||||
if (!customer) return c.json({ customer: null });
|
||||
|
||||
return c.json({
|
||||
customer: {
|
||||
displayName: customer.displayName,
|
||||
email: customer.email,
|
||||
firstName: customer.firstName,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Full profile with recent orders, for the order history page.
|
||||
account.get('/customer', async (c) => {
|
||||
const token = getSessionToken(c);
|
||||
if (!token) return c.json({ customer: null });
|
||||
|
||||
const customer = await getCustomer(token, 20);
|
||||
return c.json({ customer });
|
||||
});
|
||||
|
||||
account.post('/login', async (c) => {
|
||||
const { email, password } = await readBody(c.req.raw);
|
||||
|
||||
if (!email || !password) {
|
||||
return c.json({ error: 'Enter your email and password.' }, 400);
|
||||
}
|
||||
|
||||
const { token, errors } = await login(email, password);
|
||||
|
||||
if (!token) {
|
||||
// Shopify distinguishes wrong-password from unknown-email; collapse both so
|
||||
// the form can't be used to enumerate accounts.
|
||||
return c.json(
|
||||
{
|
||||
error: errors.length
|
||||
? 'Incorrect email or password.'
|
||||
: customerErrorMessage(errors),
|
||||
},
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
setSessionToken(c, token.accessToken, token.expiresAt);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
account.post('/logout', async (c) => {
|
||||
const token = getSessionToken(c);
|
||||
if (token) await logout(token);
|
||||
|
||||
clearSessionToken(c);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
account.post('/register', async (c) => {
|
||||
const { email, password, firstName, lastName } = await readBody(c.req.raw);
|
||||
|
||||
if (!email || !password) {
|
||||
return c.json({ error: 'Enter your email and password.' }, 400);
|
||||
}
|
||||
|
||||
const { errors } = await createCustomer({
|
||||
email,
|
||||
password,
|
||||
firstName,
|
||||
lastName,
|
||||
});
|
||||
|
||||
if (errors.length) {
|
||||
return c.json({ error: customerErrorMessage(errors) }, 400);
|
||||
}
|
||||
|
||||
// Sign the new customer straight in. Accounts needing email confirmation
|
||||
// won't return a token yet, which is not an error.
|
||||
const { token } = await login(email, password);
|
||||
|
||||
if (token) {
|
||||
setSessionToken(c, token.accessToken, token.expiresAt);
|
||||
return c.json({ ok: true, signedIn: true });
|
||||
}
|
||||
|
||||
return c.json({ ok: true, signedIn: false });
|
||||
});
|
||||
|
||||
account.post('/recover', async (c) => {
|
||||
const { email } = await readBody(c.req.raw);
|
||||
|
||||
if (email) await recoverPassword(email);
|
||||
|
||||
// Always the same response, so the form can't reveal who has an account.
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
account.post('/reset', async (c) => {
|
||||
const { id, resetToken, password } = await readBody(c.req.raw);
|
||||
|
||||
if (!id || !resetToken || !password) {
|
||||
return c.json({ error: 'This reset link is incomplete.' }, 400);
|
||||
}
|
||||
|
||||
const { token, errors } = await resetPassword(
|
||||
toCustomerGid(id),
|
||||
resetToken,
|
||||
password
|
||||
);
|
||||
|
||||
if (!token) {
|
||||
return c.json({ error: customerErrorMessage(errors) }, 400);
|
||||
}
|
||||
|
||||
setSessionToken(c, token.accessToken, token.expiresAt);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
account.post('/activate', async (c) => {
|
||||
const { id, activationToken, password } = await readBody(c.req.raw);
|
||||
|
||||
if (!id || !activationToken || !password) {
|
||||
return c.json({ error: 'This activation link is incomplete.' }, 400);
|
||||
}
|
||||
|
||||
const { token, errors } = await activateAccount(
|
||||
toCustomerGid(id),
|
||||
activationToken,
|
||||
password
|
||||
);
|
||||
|
||||
if (!token) {
|
||||
return c.json({ error: customerErrorMessage(errors) }, 400);
|
||||
}
|
||||
|
||||
setSessionToken(c, token.accessToken, token.expiresAt);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
// The API surface. In dev, @hono/vite-dev-server mounts this inside Vite; in
|
||||
// production, server/node.ts serves it alongside the built client.
|
||||
import { Hono } from 'hono';
|
||||
import { account } from './account';
|
||||
|
||||
const app = new Hono().basePath('/api');
|
||||
|
||||
app.route('/account', account);
|
||||
|
||||
app.notFound((c) => c.json({ error: 'Not found' }, 404));
|
||||
|
||||
app.onError((err, c) => {
|
||||
console.error(err);
|
||||
return c.json({ error: 'Something went wrong. Please try again.' }, 500);
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,30 @@
|
||||
// Production entry: the API plus the static client build, with an SPA fallback
|
||||
// so deep links like /products/:handle load index.html.
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { Hono } from 'hono';
|
||||
import { serve } from '@hono/node-server';
|
||||
import { serveStatic } from '@hono/node-server/serve-static';
|
||||
import api from './app';
|
||||
|
||||
const CLIENT_DIR = './dist/client';
|
||||
const indexHtml = await readFile(`${CLIENT_DIR}/index.html`, 'utf8');
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.route('/', api);
|
||||
// Unknown API paths stay JSON 404s rather than falling through to the SPA.
|
||||
app.all('/api/*', (c) => c.json({ error: 'Not found' }, 404));
|
||||
|
||||
// Hashed build assets never change, so let browsers keep them.
|
||||
app.use('/assets/*', async (c, next) => {
|
||||
await next();
|
||||
c.header('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
});
|
||||
app.use('*', serveStatic({ root: CLIENT_DIR }));
|
||||
app.get('*', (c) => c.html(indexHtml));
|
||||
|
||||
const port = Number(process.env.PORT) || 3000;
|
||||
|
||||
serve({ fetch: app.fetch, port }, (info) => {
|
||||
console.log(`Storefront listening on http://localhost:${info.port}`);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
// Customer session, stored in an httpOnly cookie. Server-only: the access token
|
||||
// must never reach the browser's JavaScript.
|
||||
import type { Context } from 'hono';
|
||||
import { getCookie, setCookie, deleteCookie } from 'hono/cookie';
|
||||
import { CUSTOMER_TOKEN_COOKIE } from '@/services/shopify/customer';
|
||||
|
||||
export function getSessionToken(c: Context): string | null {
|
||||
return getCookie(c, CUSTOMER_TOKEN_COOKIE) ?? null;
|
||||
}
|
||||
|
||||
export function setSessionToken(
|
||||
c: Context,
|
||||
accessToken: string,
|
||||
expiresAt: string
|
||||
): void {
|
||||
const expires = new Date(expiresAt);
|
||||
|
||||
setCookie(c, CUSTOMER_TOKEN_COOKIE, accessToken, {
|
||||
httpOnly: true,
|
||||
secure: import.meta.env.PROD,
|
||||
sameSite: 'Lax',
|
||||
path: '/',
|
||||
expires: Number.isNaN(expires.getTime()) ? undefined : expires,
|
||||
});
|
||||
}
|
||||
|
||||
export function clearSessionToken(c: Context): void {
|
||||
deleteCookie(c, CUSTOMER_TOKEN_COOKIE, { path: '/' });
|
||||
}
|
||||
Reference in New Issue
Block a user