create-wp-reactor 0.1.0
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/bin/create-app.js +61 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +62 -0
- package/dist/index.js.map +1 -0
- package/package.json +48 -0
- package/templates/client/.env.example +7 -0
- package/templates/client/.github/workflows/ci.yml +24 -0
- package/templates/client/.github/workflows/deploy.yml +43 -0
- package/templates/client/README.md +40 -0
- package/templates/client/apps/webapp/.env.example +10 -0
- package/templates/client/apps/webapp/README.md +14 -0
- package/templates/client/apps/webapp/blocks/cta-banner/block.json +39 -0
- package/templates/client/apps/webapp/blocks/cta-banner/edit.tsx +65 -0
- package/templates/client/apps/webapp/blocks/cta-banner/editor.scss +3 -0
- package/templates/client/apps/webapp/blocks/cta-banner/index.tsx +9 -0
- package/templates/client/apps/webapp/blocks/cta-banner/render.php +11 -0
- package/templates/client/apps/webapp/blocks/cta-banner/save.tsx +3 -0
- package/templates/client/apps/webapp/package.json +44 -0
- package/templates/client/apps/webapp/schemas/cta-banner.json +41 -0
- package/templates/client/apps/webapp/src/authOps.ts +17 -0
- package/templates/client/apps/webapp/src/blocks.tsx +29 -0
- package/templates/client/apps/webapp/src/cart.ts +28 -0
- package/templates/client/apps/webapp/src/env.ts +28 -0
- package/templates/client/apps/webapp/src/renderers/CtaBannerBlock.tsx +20 -0
- package/templates/client/apps/webapp/src/router.tsx +42 -0
- package/templates/client/apps/webapp/src/routes/__root.tsx +45 -0
- package/templates/client/apps/webapp/src/routes/index.tsx +51 -0
- package/templates/client/apps/webapp/src/routes/preview.tsx +32 -0
- package/templates/client/apps/webapp/src/server/auth.ts +66 -0
- package/templates/client/apps/webapp/src/server/authMiddleware.ts +79 -0
- package/templates/client/apps/webapp/src/server/cart.ts +68 -0
- package/templates/client/apps/webapp/src/server/cartMiddleware.ts +60 -0
- package/templates/client/apps/webapp/src/server/session.ts +34 -0
- package/templates/client/apps/webapp/src/start.ts +19 -0
- package/templates/client/apps/webapp/src/styles.css +1 -0
- package/templates/client/apps/webapp/tsconfig.json +12 -0
- package/templates/client/apps/webapp/vite.config.ts +49 -0
- package/templates/client/apps/webapp/wp-reactor.config.json +13 -0
- package/templates/client/apps/wordpress/Dockerfile +11 -0
- package/templates/client/apps/wordpress/docker/child-entrypoint.sh +13 -0
- package/templates/client/apps/wordpress/theme-__PROJECT_NAME__/functions.php +12 -0
- package/templates/client/apps/wordpress/theme-__PROJECT_NAME__/index.php +2 -0
- package/templates/client/apps/wordpress/theme-__PROJECT_NAME__/src/blocks/.gitkeep +0 -0
- package/templates/client/apps/wordpress/theme-__PROJECT_NAME__/style.css +8 -0
- package/templates/client/apps/wordpress/theme-__PROJECT_NAME__/theme.json +5 -0
- package/templates/client/gitignore +14 -0
- package/templates/client/npmrc +5 -0
- package/templates/client/package.json +20 -0
- package/templates/client/pnpm-workspace.yaml +3 -0
- package/templates/client/turbo.json +9 -0
- package/templates/client/wp-reactor.config.json +9 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cacheTimingsFromEnv,
|
|
3
|
+
createRuntimeEnv,
|
|
4
|
+
type EnvSource,
|
|
5
|
+
} from "@wp-reactor/headless-core";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* La COQUE possède la lecture de l'env (contrat ADR 0001) : elle construit une
|
|
9
|
+
* `EnvSource` depuis SON `import.meta.env` / `process.env` et la passe au kernel.
|
|
10
|
+
* Les packages ne lisent jamais l'env directement.
|
|
11
|
+
*/
|
|
12
|
+
const envSource: EnvSource = {
|
|
13
|
+
get(key) {
|
|
14
|
+
// VITE_* est inliné par Vite ; le reste vient de process.env côté serveur.
|
|
15
|
+
const fromVite = (import.meta.env as Record<string, string | undefined>)[
|
|
16
|
+
key
|
|
17
|
+
];
|
|
18
|
+
if (fromVite !== undefined) return fromVite;
|
|
19
|
+
return typeof process !== "undefined" ? process.env[key] : undefined;
|
|
20
|
+
},
|
|
21
|
+
ssr: import.meta.env.SSR,
|
|
22
|
+
dev: import.meta.env.DEV,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const runtimeEnv = createRuntimeEnv(envSource);
|
|
26
|
+
export const cacheTimings = cacheTimingsFromEnv(envSource);
|
|
27
|
+
export const isSsr = import.meta.env.SSR;
|
|
28
|
+
export const isDev = import.meta.env.DEV;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface CtaBannerBlockAttributes {
|
|
2
|
+
heading: string;
|
|
3
|
+
body: string;
|
|
4
|
+
inverted: boolean;
|
|
5
|
+
items: unknown[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface CtaBannerBlockProps {
|
|
9
|
+
attributes?: CtaBannerBlockAttributes;
|
|
10
|
+
preview?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function CtaBannerBlock({ attributes }: CtaBannerBlockProps) {
|
|
14
|
+
// TODO: implémenter le rendu du bloc "wp-reactor/cta-banner".
|
|
15
|
+
return (
|
|
16
|
+
<section data-block="wp-reactor/cta-banner">
|
|
17
|
+
<pre>{JSON.stringify(attributes, null, 2)}</pre>
|
|
18
|
+
</section>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createGraphQLClient,
|
|
3
|
+
createGraphQLReadClient,
|
|
4
|
+
createQueryContext,
|
|
5
|
+
} from "@wp-reactor/headless-core";
|
|
6
|
+
import type { QueryClient } from "@tanstack/react-query";
|
|
7
|
+
import { createRouter } from "@tanstack/react-router";
|
|
8
|
+
import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query";
|
|
9
|
+
import type { GraphQLClient } from "graphql-request";
|
|
10
|
+
import { runtimeEnv } from "./env";
|
|
11
|
+
import { routeTree } from "./routeTree.gen";
|
|
12
|
+
|
|
13
|
+
// Le contexte du routeur porte les fabriques du kernel, instanciées avec l'env
|
|
14
|
+
// de la coque. Réf : .reference/namaki/apps/webapp/src/router.tsx
|
|
15
|
+
export interface RouterContext {
|
|
16
|
+
locale: "fr" | "en" | "de";
|
|
17
|
+
graphqlClient: GraphQLClient;
|
|
18
|
+
graphqlReadClient: GraphQLClient;
|
|
19
|
+
queryClient: QueryClient;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const getRouter = () => {
|
|
23
|
+
const graphqlClient = createGraphQLClient(runtimeEnv);
|
|
24
|
+
const graphqlReadClient = createGraphQLReadClient(runtimeEnv);
|
|
25
|
+
const { queryClient } = createQueryContext();
|
|
26
|
+
|
|
27
|
+
const router = createRouter({
|
|
28
|
+
routeTree,
|
|
29
|
+
context: {
|
|
30
|
+
locale: "fr",
|
|
31
|
+
graphqlClient,
|
|
32
|
+
graphqlReadClient,
|
|
33
|
+
queryClient,
|
|
34
|
+
} satisfies RouterContext,
|
|
35
|
+
defaultPreload: "intent",
|
|
36
|
+
scrollRestoration: true,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
setupRouterSsrQueryIntegration({ router, queryClient });
|
|
40
|
+
|
|
41
|
+
return router;
|
|
42
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { AuthProvider } from "@wp-reactor/auth";
|
|
2
|
+
import { GraphQLClientProvider } from "@wp-reactor/headless-core";
|
|
3
|
+
import {
|
|
4
|
+
createRootRouteWithContext,
|
|
5
|
+
HeadContent,
|
|
6
|
+
Outlet,
|
|
7
|
+
Scripts,
|
|
8
|
+
} from "@tanstack/react-router";
|
|
9
|
+
import "../styles.css";
|
|
10
|
+
import { authOperations } from "@/authOps";
|
|
11
|
+
import type { RouterContext } from "@/router";
|
|
12
|
+
|
|
13
|
+
export const Route = createRootRouteWithContext<RouterContext>()({
|
|
14
|
+
head: () => ({
|
|
15
|
+
meta: [
|
|
16
|
+
{ charSet: "utf-8" },
|
|
17
|
+
{ name: "viewport", content: "width=device-width, initial-scale=1" },
|
|
18
|
+
{ title: "WP Reactor" },
|
|
19
|
+
],
|
|
20
|
+
}),
|
|
21
|
+
component: RootComponent,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function RootComponent() {
|
|
25
|
+
const { graphqlClient, graphqlReadClient } = Route.useRouteContext();
|
|
26
|
+
return (
|
|
27
|
+
<html lang="fr">
|
|
28
|
+
<head>
|
|
29
|
+
<HeadContent />
|
|
30
|
+
</head>
|
|
31
|
+
<body>
|
|
32
|
+
{/* Clients GraphQL du kernel + auth à opérations injectées (ADR 0001).
|
|
33
|
+
react-query est fourni par setupRouterSsrQueryIntegration. */}
|
|
34
|
+
<GraphQLClientProvider
|
|
35
|
+
value={{ client: graphqlClient, readClient: graphqlReadClient }}
|
|
36
|
+
>
|
|
37
|
+
<AuthProvider operations={authOperations}>
|
|
38
|
+
<Outlet />
|
|
39
|
+
</AuthProvider>
|
|
40
|
+
</GraphQLClientProvider>
|
|
41
|
+
<Scripts />
|
|
42
|
+
</body>
|
|
43
|
+
</html>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { EditorBlocks, type EditorBlock } from "@wp-reactor/editorial";
|
|
2
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
3
|
+
import { blockRegistry } from "@/blocks";
|
|
4
|
+
import { useCart } from "@/cart";
|
|
5
|
+
import { isDev } from "@/env";
|
|
6
|
+
|
|
7
|
+
// En vrai, ces blocs viennent de WordPress via `nodeByUri` (GraphQL). Ici, un
|
|
8
|
+
// échantillon statique pour démontrer le dispatch du moteur de blocs.
|
|
9
|
+
const demoBlocks: EditorBlock[] = [
|
|
10
|
+
{
|
|
11
|
+
name: "wp-reactor/hero",
|
|
12
|
+
clientId: "demo-1",
|
|
13
|
+
attributes: { title: "WP Reactor — starter headless" },
|
|
14
|
+
},
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export const Route = createFileRoute("/")({
|
|
18
|
+
component: Home,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function Home() {
|
|
22
|
+
const { itemCount, addToCart, isAddingToCart } = useCart();
|
|
23
|
+
return (
|
|
24
|
+
<main>
|
|
25
|
+
<EditorBlocks blocks={demoBlocks} registry={blockRegistry} dev={isDev} />
|
|
26
|
+
<section className="mx-auto max-w-2xl p-12">
|
|
27
|
+
<div className="mb-6 flex items-center gap-3">
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
disabled={isAddingToCart}
|
|
31
|
+
onClick={() =>
|
|
32
|
+
addToCart({
|
|
33
|
+
productId: 1,
|
|
34
|
+
optimistic: { name: "Démo", price: "9,90 €" },
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
className="rounded bg-neutral-900 px-4 py-2 text-white disabled:opacity-50"
|
|
38
|
+
>
|
|
39
|
+
Ajouter au panier (démo)
|
|
40
|
+
</button>
|
|
41
|
+
<span className="text-neutral-600">Panier : {itemCount}</span>
|
|
42
|
+
</div>
|
|
43
|
+
<p className="text-neutral-600">
|
|
44
|
+
Brancher WordPress via <code>@wp-reactor/headless-core</code>, rendre
|
|
45
|
+
les blocs avec <code>@wp-reactor/editorial</code>, panier/compte via{" "}
|
|
46
|
+
<code>@wp-reactor/commerce</code> et <code>@wp-reactor/auth</code>.
|
|
47
|
+
</p>
|
|
48
|
+
</section>
|
|
49
|
+
</main>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {
|
|
2
|
+
parsePreviewData,
|
|
3
|
+
PreviewBlockHost,
|
|
4
|
+
type PreviewBlockSearch,
|
|
5
|
+
validatePreviewBlockSearch,
|
|
6
|
+
} from "@wp-reactor/editorial";
|
|
7
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
8
|
+
import { blockRegistry } from "@/blocks";
|
|
9
|
+
|
|
10
|
+
// Route iframe du live-preview. L'éditeur Gutenberg (FrontPreviewFrame de
|
|
11
|
+
// @wp-reactor/block-editor) pointe ici ; PreviewBlockHost gère tout le protocole
|
|
12
|
+
// postMessage avec le registre de blocs du client.
|
|
13
|
+
export const Route = createFileRoute("/preview")({
|
|
14
|
+
validateSearch: (search): PreviewBlockSearch =>
|
|
15
|
+
validatePreviewBlockSearch(search),
|
|
16
|
+
component: PreviewPage,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function PreviewPage() {
|
|
20
|
+
const search = Route.useSearch();
|
|
21
|
+
const block = search.block ?? "wp-reactor/hero";
|
|
22
|
+
return (
|
|
23
|
+
<PreviewBlockHost
|
|
24
|
+
key={`${search.frame ?? "no-frame"}-${block}`}
|
|
25
|
+
registry={blockRegistry}
|
|
26
|
+
block={block}
|
|
27
|
+
frame={search.frame}
|
|
28
|
+
editorOrigin={search.editorOrigin}
|
|
29
|
+
initialData={parsePreviewData(search.data)}
|
|
30
|
+
/>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AuthUser,
|
|
3
|
+
LOGIN_MUTATION,
|
|
4
|
+
normalizeAuthTokens,
|
|
5
|
+
} from "@wp-reactor/auth";
|
|
6
|
+
import { WOO_SESSION_COOKIE } from "@wp-reactor/commerce";
|
|
7
|
+
import { createServerGraphQLClient } from "@wp-reactor/headless-core";
|
|
8
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
9
|
+
import { getCookie, setCookie } from "@tanstack/react-start/server";
|
|
10
|
+
import { runtimeEnv } from "../env";
|
|
11
|
+
import { authMiddleware } from "./authMiddleware";
|
|
12
|
+
import { useAppSession } from "./session";
|
|
13
|
+
|
|
14
|
+
// Wrappers serverFn MINCES autour des contrats du package @wp-reactor/auth
|
|
15
|
+
// (Option B). POST obligatoire : réponses personnalisées par la session, jamais
|
|
16
|
+
// cachables en edge.
|
|
17
|
+
|
|
18
|
+
async function establishSession(
|
|
19
|
+
username: string,
|
|
20
|
+
password: string,
|
|
21
|
+
): Promise<AuthUser> {
|
|
22
|
+
const client = createServerGraphQLClient(runtimeEnv);
|
|
23
|
+
|
|
24
|
+
// Transfert du panier invité → compte : on présente le woo-session guest.
|
|
25
|
+
const guestSession = getCookie(WOO_SESSION_COOKIE);
|
|
26
|
+
if (guestSession) {
|
|
27
|
+
client.setHeader("woocommerce-session", `Session ${guestSession}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const res = await client.request(LOGIN_MUTATION, { username, password });
|
|
31
|
+
if (!res?.login) throw new Error("Login failed");
|
|
32
|
+
const { user, ...tokens } = res.login;
|
|
33
|
+
|
|
34
|
+
const session = await useAppSession();
|
|
35
|
+
await session.update({ ...normalizeAuthTokens(tokens), user });
|
|
36
|
+
|
|
37
|
+
// Panier rattaché au compte (Bearer) → cookie invité obsolète.
|
|
38
|
+
if (guestSession) {
|
|
39
|
+
setCookie(WOO_SESSION_COOKIE, "", { path: "/", maxAge: 0 });
|
|
40
|
+
}
|
|
41
|
+
return user;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const getCurrentUserServerFn = createServerFn({ method: "POST" })
|
|
45
|
+
.middleware([authMiddleware])
|
|
46
|
+
.handler(({ context }) => ({
|
|
47
|
+
user: context.user,
|
|
48
|
+
isAuthenticated: context.isAuthenticated,
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
export const loginServerFn = createServerFn({ method: "POST" })
|
|
52
|
+
.inputValidator((data: { username: string; password: string }) => ({
|
|
53
|
+
username: String(data.username),
|
|
54
|
+
password: String(data.password),
|
|
55
|
+
}))
|
|
56
|
+
.handler(async ({ data }) => ({
|
|
57
|
+
user: await establishSession(data.username, data.password),
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
export const logoutServerFn = createServerFn({ method: "POST" }).handler(
|
|
61
|
+
async () => {
|
|
62
|
+
const session = await useAppSession();
|
|
63
|
+
await session.clear();
|
|
64
|
+
return { ok: true };
|
|
65
|
+
},
|
|
66
|
+
);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AuthUser,
|
|
3
|
+
REFRESH_TOKEN_MUTATION,
|
|
4
|
+
resolveSessionAction,
|
|
5
|
+
} from "@wp-reactor/auth";
|
|
6
|
+
import { createServerGraphQLClient } from "@wp-reactor/headless-core";
|
|
7
|
+
import { createMiddleware } from "@tanstack/react-start";
|
|
8
|
+
import { runtimeEnv } from "../env";
|
|
9
|
+
import { useAppSession } from "./session";
|
|
10
|
+
|
|
11
|
+
export interface AuthContext {
|
|
12
|
+
isAuthenticated: boolean;
|
|
13
|
+
user: AuthUser | null;
|
|
14
|
+
authToken: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Coque (Option B) : lit la session scellée, décide via `resolveSessionAction`
|
|
19
|
+
* (helper PUR du package), rafraîchit/purge le JWT, et expose `{ isAuthenticated,
|
|
20
|
+
* user, authToken }` aux serverFns. Aucun token ne transite vers le client.
|
|
21
|
+
*/
|
|
22
|
+
export const authMiddleware = createMiddleware().server(async ({ next }) => {
|
|
23
|
+
const session = await useAppSession();
|
|
24
|
+
const data = session.data;
|
|
25
|
+
const tokens = {
|
|
26
|
+
authToken: data.authToken ?? "",
|
|
27
|
+
refreshToken: data.refreshToken ?? "",
|
|
28
|
+
authTokenExpiration: data.authTokenExpiration ?? 0,
|
|
29
|
+
refreshTokenExpiration: data.refreshTokenExpiration ?? 0,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
let authToken = tokens.authToken;
|
|
33
|
+
let user: AuthUser | null = data.user ?? null;
|
|
34
|
+
let isAuthenticated = false;
|
|
35
|
+
|
|
36
|
+
switch (resolveSessionAction(tokens)) {
|
|
37
|
+
case "authenticated":
|
|
38
|
+
isAuthenticated = true;
|
|
39
|
+
break;
|
|
40
|
+
case "refresh": {
|
|
41
|
+
// Même X-Forwarded-Host que login/panier (claim `iss` du JWT).
|
|
42
|
+
const client = createServerGraphQLClient(runtimeEnv);
|
|
43
|
+
try {
|
|
44
|
+
const res = await client.request(REFRESH_TOKEN_MUTATION, {
|
|
45
|
+
token: tokens.refreshToken,
|
|
46
|
+
});
|
|
47
|
+
if (res?.refreshToken?.success) {
|
|
48
|
+
authToken = res.refreshToken.authToken;
|
|
49
|
+
await session.update({
|
|
50
|
+
authToken,
|
|
51
|
+
authTokenExpiration: res.refreshToken.authTokenExpiration,
|
|
52
|
+
});
|
|
53
|
+
isAuthenticated = true;
|
|
54
|
+
} else {
|
|
55
|
+
await session.clear();
|
|
56
|
+
user = null;
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
await session.clear();
|
|
60
|
+
user = null;
|
|
61
|
+
}
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
case "clear":
|
|
65
|
+
await session.clear();
|
|
66
|
+
user = null;
|
|
67
|
+
break;
|
|
68
|
+
case "anonymous":
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return next({
|
|
73
|
+
context: {
|
|
74
|
+
isAuthenticated,
|
|
75
|
+
user: isAuthenticated ? user : null,
|
|
76
|
+
authToken: isAuthenticated ? authToken : "",
|
|
77
|
+
} satisfies AuthContext,
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createCartHandlers } from "@wp-reactor/commerce";
|
|
2
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
3
|
+
import { cartMiddleware } from "./cartMiddleware";
|
|
4
|
+
|
|
5
|
+
// Wrappers serverFn MINCES autour de `createCartHandlers` (Option B). Le timing
|
|
6
|
+
// Woo est injecté depuis l'env de la coque.
|
|
7
|
+
const timing = process.env.WOO_TIMING === "1";
|
|
8
|
+
|
|
9
|
+
export const getCart = createServerFn({ method: "POST" })
|
|
10
|
+
.middleware([cartMiddleware])
|
|
11
|
+
.handler(({ context }) => createCartHandlers({ ...context, timing }).getCart());
|
|
12
|
+
|
|
13
|
+
export const addCartItem = createServerFn({ method: "POST" })
|
|
14
|
+
.middleware([cartMiddleware])
|
|
15
|
+
.inputValidator(
|
|
16
|
+
(data: { productId: number; quantity?: number; variationId?: number }) => ({
|
|
17
|
+
productId: Number(data.productId),
|
|
18
|
+
quantity: data.quantity != null ? Number(data.quantity) : undefined,
|
|
19
|
+
variationId:
|
|
20
|
+
data.variationId != null ? Number(data.variationId) : undefined,
|
|
21
|
+
}),
|
|
22
|
+
)
|
|
23
|
+
.handler(({ context, data }) =>
|
|
24
|
+
createCartHandlers({ ...context, timing }).addCartItem(data),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
export const removeCartItems = createServerFn({ method: "POST" })
|
|
28
|
+
.middleware([cartMiddleware])
|
|
29
|
+
.inputValidator((data: { keys: string[] }) => ({
|
|
30
|
+
keys: data.keys.map((k) => String(k)),
|
|
31
|
+
}))
|
|
32
|
+
.handler(({ context, data }) =>
|
|
33
|
+
createCartHandlers({ ...context, timing }).removeCartItems(data.keys),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
export const updateCartItemQuantities = createServerFn({ method: "POST" })
|
|
37
|
+
.middleware([cartMiddleware])
|
|
38
|
+
.inputValidator((data: { items: Array<{ key: string; quantity: number }> }) => ({
|
|
39
|
+
items: data.items.map((i) => ({
|
|
40
|
+
key: String(i.key),
|
|
41
|
+
quantity: Number(i.quantity),
|
|
42
|
+
})),
|
|
43
|
+
}))
|
|
44
|
+
.handler(({ context, data }) =>
|
|
45
|
+
createCartHandlers({ ...context, timing }).updateCartItemQuantities(data.items),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
export const applyCoupon = createServerFn({ method: "POST" })
|
|
49
|
+
.middleware([cartMiddleware])
|
|
50
|
+
.inputValidator((data: { code: string }) => ({ code: String(data.code) }))
|
|
51
|
+
.handler(({ context, data }) =>
|
|
52
|
+
createCartHandlers({ ...context, timing }).applyCoupon(data.code),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
export const removeCoupons = createServerFn({ method: "POST" })
|
|
56
|
+
.middleware([cartMiddleware])
|
|
57
|
+
.inputValidator((data: { codes: string[] }) => ({
|
|
58
|
+
codes: data.codes.map((c) => String(c)),
|
|
59
|
+
}))
|
|
60
|
+
.handler(({ context, data }) =>
|
|
61
|
+
createCartHandlers({ ...context, timing }).removeCoupons(data.codes),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
export const getCheckoutUrl = createServerFn({ method: "POST" })
|
|
65
|
+
.middleware([cartMiddleware])
|
|
66
|
+
.handler(({ context }) =>
|
|
67
|
+
createCartHandlers({ ...context, timing }).getCheckoutUrl(),
|
|
68
|
+
);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {
|
|
2
|
+
normalizeWooSessionToken,
|
|
3
|
+
WOO_SESSION_COOKIE,
|
|
4
|
+
WOO_SESSION_MAX_AGE,
|
|
5
|
+
} from "@wp-reactor/commerce";
|
|
6
|
+
import { ssrForwardedHeaders } from "@wp-reactor/headless-core";
|
|
7
|
+
import { createMiddleware } from "@tanstack/react-start";
|
|
8
|
+
import { getCookie, setCookie } from "@tanstack/react-start/server";
|
|
9
|
+
import { GraphQLClient } from "graphql-request";
|
|
10
|
+
import { runtimeEnv } from "../env";
|
|
11
|
+
import { authMiddleware } from "./authMiddleware";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Coque (Option B) : construit le client GraphQL panier — Bearer si connecté,
|
|
15
|
+
* sinon token woo-session invité (et réécrit le token roté dans le cookie). Le
|
|
16
|
+
* contexte est consommé par `createCartHandlers` (@wp-reactor/commerce).
|
|
17
|
+
*/
|
|
18
|
+
export const cartMiddleware = createMiddleware()
|
|
19
|
+
.middleware([authMiddleware])
|
|
20
|
+
.server(async ({ next, context }) => {
|
|
21
|
+
const headers: Record<string, string> = ssrForwardedHeaders(
|
|
22
|
+
runtimeEnv.wordpressHost,
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const isGuest = !context.authToken;
|
|
26
|
+
const wooSessionToken = isGuest ? getCookie(WOO_SESSION_COOKIE) : null;
|
|
27
|
+
if (context.authToken) {
|
|
28
|
+
headers.Authorization = `Bearer ${context.authToken}`;
|
|
29
|
+
} else if (wooSessionToken) {
|
|
30
|
+
headers["woocommerce-session"] = `Session ${wooSessionToken}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const graphqlClient = new GraphQLClient(runtimeEnv.endpoint, {
|
|
34
|
+
credentials: "omit",
|
|
35
|
+
headers,
|
|
36
|
+
responseMiddleware: (response) => {
|
|
37
|
+
if (!isGuest) return;
|
|
38
|
+
if (response instanceof Error) return;
|
|
39
|
+
const rotated = response.headers?.get?.("woocommerce-session");
|
|
40
|
+
if (!rotated) return;
|
|
41
|
+
const normalized = normalizeWooSessionToken(rotated);
|
|
42
|
+
if (!normalized) return;
|
|
43
|
+
setCookie(WOO_SESSION_COOKIE, normalized, {
|
|
44
|
+
httpOnly: true,
|
|
45
|
+
secure: process.env.NODE_ENV === "production",
|
|
46
|
+
sameSite: "lax",
|
|
47
|
+
path: "/",
|
|
48
|
+
maxAge: WOO_SESSION_MAX_AGE,
|
|
49
|
+
});
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return next({
|
|
54
|
+
context: {
|
|
55
|
+
graphqlClient,
|
|
56
|
+
isAuthenticated: context.isAuthenticated,
|
|
57
|
+
hasWooSession: !!wooSessionToken,
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_SESSION_MAX_AGE,
|
|
3
|
+
DEFAULT_SESSION_NAME,
|
|
4
|
+
SESSION_SECRET_MIN_LENGTH,
|
|
5
|
+
type SessionData,
|
|
6
|
+
} from "@wp-reactor/auth";
|
|
7
|
+
import { useSession } from "@tanstack/react-start/server";
|
|
8
|
+
|
|
9
|
+
// I/O de la session scellée — server-only, vit dans la COQUE (Option B, ADR 0001).
|
|
10
|
+
// Le package @wp-reactor/auth fournit le contrat (SessionData) + les constantes.
|
|
11
|
+
|
|
12
|
+
function getSessionSecret(): string {
|
|
13
|
+
const secret = process.env.SESSION_SECRET;
|
|
14
|
+
if (!secret || secret.length < SESSION_SECRET_MIN_LENGTH) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
"[auth] SESSION_SECRET must be set (>= 32 chars). Generate one with `openssl rand -base64 48`.",
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
return secret;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useAppSession() {
|
|
23
|
+
return useSession<SessionData>({
|
|
24
|
+
password: getSessionSecret(),
|
|
25
|
+
name: DEFAULT_SESSION_NAME,
|
|
26
|
+
maxAge: DEFAULT_SESSION_MAX_AGE,
|
|
27
|
+
cookie: {
|
|
28
|
+
httpOnly: true,
|
|
29
|
+
secure: process.env.NODE_ENV === "production",
|
|
30
|
+
sameSite: "lax",
|
|
31
|
+
path: "/",
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createCacheControlMiddleware } from "@wp-reactor/headless-core";
|
|
2
|
+
import { createCsrfMiddleware, createStart } from "@tanstack/react-start";
|
|
3
|
+
|
|
4
|
+
// Définir un startInstance REMPLACE le defaultCsrfMiddleware injecté par le
|
|
5
|
+
// framework. On le ré-ajoute, sinon les serverFns ne sont plus protégés cross-site.
|
|
6
|
+
// Réf : .reference/namaki/apps/webapp/src/start.ts
|
|
7
|
+
const csrfMiddleware = createCsrfMiddleware({
|
|
8
|
+
filter: (ctx) => ctx.handlerType === "serverFn",
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
// Edge-cache du HTML SSR — factory du kernel, paramétrée par l'env de la coque.
|
|
12
|
+
const cacheControlMiddleware = createCacheControlMiddleware({
|
|
13
|
+
sharedMaxAge: Number(process.env.HTML_EDGE_MAX_AGE ?? 600),
|
|
14
|
+
staleWhileRevalidate: Number(process.env.HTML_EDGE_SWR ?? 86_400),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const startInstance = createStart(() => ({
|
|
18
|
+
requestMiddleware: [csrfMiddleware, cacheControlMiddleware],
|
|
19
|
+
}));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "@wp-reactor/config-tsconfig/react-lib.json",
|
|
3
|
+
"include": ["**/*.ts", "**/*.tsx"],
|
|
4
|
+
"//": "blocks/ = fichiers éditeur Gutenberg (territoire du thème WP, toolchain @wordpress/*), hors compilation webapp.",
|
|
5
|
+
"exclude": ["node_modules", "blocks"],
|
|
6
|
+
"compilerOptions": {
|
|
7
|
+
"types": ["vite/client", "react", "react-dom"],
|
|
8
|
+
"paths": {
|
|
9
|
+
"@/*": ["./src/*"]
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { fileURLToPath, URL } from "node:url";
|
|
2
|
+
import tailwindcss from "@tailwindcss/vite";
|
|
3
|
+
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
|
|
4
|
+
import viteReact from "@vitejs/plugin-react";
|
|
5
|
+
import { nitro } from "nitro/vite";
|
|
6
|
+
import { defineConfig } from "vite";
|
|
7
|
+
|
|
8
|
+
// En dev, les packages @wp-reactor/* sont consommés en source via le workspace.
|
|
9
|
+
// Aliases explicites pour les packages présentiels/runtime résolus en `src/index.ts`.
|
|
10
|
+
const pkg = (name: string) =>
|
|
11
|
+
fileURLToPath(new URL(`../packages/${name}/src/index.ts`, import.meta.url));
|
|
12
|
+
|
|
13
|
+
export default defineConfig(({ command }) => ({
|
|
14
|
+
// Les .env vivent à la racine du repo client (mono-source d'env).
|
|
15
|
+
envDir: "..",
|
|
16
|
+
...(command !== "build"
|
|
17
|
+
? {}
|
|
18
|
+
: {
|
|
19
|
+
ssr: {
|
|
20
|
+
noExternal: true,
|
|
21
|
+
resolve: {
|
|
22
|
+
conditions: ["import", "module", "default"],
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
}),
|
|
26
|
+
resolve: {
|
|
27
|
+
alias: {
|
|
28
|
+
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
|
29
|
+
"@wp-reactor/ui": pkg("ui"),
|
|
30
|
+
"@wp-reactor/headless-core": pkg("headless-core"),
|
|
31
|
+
"@wp-reactor/editorial": pkg("editorial"),
|
|
32
|
+
"@wp-reactor/commerce": pkg("commerce"),
|
|
33
|
+
"@wp-reactor/auth": pkg("auth"),
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
server: {
|
|
37
|
+
allowedHosts: process.env.VITE_ALLOWED_HOST
|
|
38
|
+
? [process.env.VITE_ALLOWED_HOST]
|
|
39
|
+
: [],
|
|
40
|
+
watch: {
|
|
41
|
+
usePolling: process.env.CHOKIDAR_USEPOLLING === "true",
|
|
42
|
+
interval: 300,
|
|
43
|
+
},
|
|
44
|
+
fs: {
|
|
45
|
+
allow: [fileURLToPath(new URL("..", import.meta.url))],
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
plugins: [nitro(), tailwindcss(), tanstackStart(), viteReact()],
|
|
49
|
+
}));
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"namespace": "wp-reactor",
|
|
3
|
+
"schemasDir": "schemas",
|
|
4
|
+
"theme": {
|
|
5
|
+
"blocksDir": "blocks"
|
|
6
|
+
},
|
|
7
|
+
"webapp": {
|
|
8
|
+
"renderersDir": "src/renderers",
|
|
9
|
+
"registryFile": "src/blocks.tsx",
|
|
10
|
+
"registryMarker": "// @gen:block:block-registry",
|
|
11
|
+
"renderersImportBase": "./renderers"
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# syntax=docker/dockerfile:1.7
|
|
2
|
+
# Image WordPress client __PROJECT_TITLE__ : part de l'image base du framework
|
|
3
|
+
# (WordPress + thème parent wp-reactor-base + plugin) et overlaye le thème enfant.
|
|
4
|
+
FROM ghcr.io/wp-reactor/wordpress-base:latest
|
|
5
|
+
|
|
6
|
+
COPY apps/wordpress/theme-__PROJECT_NAME__ /opt/__PROJECT_NAME__/theme
|
|
7
|
+
COPY apps/wordpress/docker/child-entrypoint.sh /usr/local/bin/__PROJECT_NAME__-entrypoint.sh
|
|
8
|
+
RUN chmod +x /usr/local/bin/__PROJECT_NAME__-entrypoint.sh
|
|
9
|
+
|
|
10
|
+
ENTRYPOINT ["__PROJECT_NAME__-entrypoint.sh"]
|
|
11
|
+
CMD ["apache2-foreground"]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
# Overlay le thème ENFANT baké, puis délègue à l'entrypoint base WP Reactor
|
|
4
|
+
# (qui overlaye le thème parent + plugin et lance WordPress).
|
|
5
|
+
WP_CONTENT="${WP_CONTENT_DIR:-/var/www/html/wp-content}"
|
|
6
|
+
if [ -d /opt/__PROJECT_NAME__/theme ]; then
|
|
7
|
+
echo "[__PROJECT_NAME__-entrypoint] overlay thème enfant -> $WP_CONTENT/themes/__PROJECT_NAME__"
|
|
8
|
+
rm -rf "$WP_CONTENT/themes/__PROJECT_NAME__"
|
|
9
|
+
mkdir -p "$WP_CONTENT/themes"
|
|
10
|
+
cp -a /opt/__PROJECT_NAME__/theme "$WP_CONTENT/themes/__PROJECT_NAME__"
|
|
11
|
+
chown -R www-data:www-data "$WP_CONTENT/themes/__PROJECT_NAME__" 2>/dev/null || true
|
|
12
|
+
fi
|
|
13
|
+
exec wp-reactor-entrypoint.sh "$@"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
// Thème enfant __PROJECT_TITLE__. Le parent wp-reactor-base (image GHCR base)
|
|
3
|
+
// porte la logique commune ; ici : branding + enregistrement des blocs propres.
|
|
4
|
+
|
|
5
|
+
if (!defined('ABSPATH')) {
|
|
6
|
+
exit;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
add_action('wp_enqueue_scripts', function () {
|
|
10
|
+
wp_enqueue_style('wp-reactor-base', get_template_directory_uri() . '/style.css');
|
|
11
|
+
wp_enqueue_style('__PROJECT_NAME__', get_stylesheet_uri(), ['wp-reactor-base']);
|
|
12
|
+
});
|