diff --git a/packages/stripe/.gitignore b/packages/stripe/.gitignore new file mode 100644 index 000000000..3311aa29c --- /dev/null +++ b/packages/stripe/.gitignore @@ -0,0 +1,7 @@ +.env* +!.env.example +**/*.log +coverage +dist +dist-ssr +node_modules diff --git a/packages/stripe/eslint.config.js b/packages/stripe/eslint.config.js new file mode 100644 index 000000000..9fb397fb0 --- /dev/null +++ b/packages/stripe/eslint.config.js @@ -0,0 +1,19 @@ +import reactConfig from "@prefabs.tech/eslint-config/react.js"; + +export default [ + ...reactConfig, + { + rules: { + "unicorn/filename-case": [ + "error", + { + cases: { + camelCase: true, + kebabCase: true, + pascalCase: true, + }, + }, + ], + }, + }, +]; diff --git a/packages/stripe/package.json b/packages/stripe/package.json new file mode 100644 index 000000000..eec0b28b1 --- /dev/null +++ b/packages/stripe/package.json @@ -0,0 +1,84 @@ +{ + "name": "@prefabs.tech/react-stripe", + "version": "0.72.1", + "description": "React Stripe Plugin", + "type": "module", + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "import": "./dist/PrefabsTechReactStripe.es.js", + "require": "./dist/PrefabsTechReactStripe.umd.js" + }, + "./dist/PrefabsTechReactStripe.css": "./dist/react-stripe.css" + }, + "main": "./dist/PrefabsTechReactStripe.umd.js", + "module": "./dist/PrefabsTechReactStripe.es.js", + "types": "./dist/src/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "vite build && tsc --emitDeclarationOnly", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "snapshot:update": "vitest --environment jsdom run --update --passWithNoTests", + "sort-package": "npx sort-package-json", + "stylelint": "stylelint \"src/**/*.{css}\" --allow-empty-input", + "stylelint:fix": "stylelint \"src/**/*.{css}\" --fix --allow-empty-input", + "test": "pnpm build && vitest --environment jsdom run --coverage --passWithNoTests", + "test:component": "vitest --environment jsdom run component/ --passWithNoTests", + "test:snapshot": "vitest --environment jsdom run snapshot/ --passWithNoTests", + "test:unit": "vitest --environment jsdom run unit/ --passWithNoTests", + "test:watch": "vitest --environment jsdom", + "typecheck": "tsc --noEmit -p tsconfig.vitest.json --composite false" + }, + "dependencies": {}, + "devDependencies": { + "@prefabs.tech/eslint-config": "0.7.0", + "@prefabs.tech/react-i18n": "0.72.1", + "@prefabs.tech/react-ui": "0.72.1", + "@prefabs.tech/react-user": "0.72.1", + "@prefabs.tech/stylelint-config": "0.7.0", + "@prefabs.tech/tsconfig": "0.7.0", + "@testing-library/dom": "10.4.1", + "@testing-library/react": "16.3.2", + "@testing-library/user-event": "14.6.1", + "@types/jsdom": "21.1.7", + "@types/node": "25.6.0", + "@types/react": "18.3.28", + "@types/react-dom": "18.3.7", + "@vitejs/plugin-react": "5.2.0", + "@vitest/coverage-v8": "3.2.4", + "axios": "1.16.0", + "eslint": "9.39.4", + "jsdom": "27.4.0", + "prettier": "3.8.3", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-router-dom": "6.30.3", + "stylelint": "17.10.0", + "stylelint-config-standard": "40.0.0", + "stylelint-order": "8.1.1", + "typescript": "5.9.3", + "vite": "7.3.2", + "vitest": "3.2.4" + }, + "peerDependencies": { + "@prefabs.tech/react-i18n": "0.72.1", + "@prefabs.tech/react-ui": "0.72.1", + "@prefabs.tech/react-user": "0.72.1", + "axios": "1.16.0", + "react": ">=18.3", + "react-dom": ">=18.3", + "react-router-dom": ">=6.30" + }, + "peerDependenciesMeta": { + "@prefabs.tech/react-user": { + "optional": true + } + }, + "engines": { + "node": ">=18", + "pnpm": ">=9" + } +} diff --git a/packages/stripe/src/api/index.ts b/packages/stripe/src/api/index.ts new file mode 100644 index 000000000..90bb689d7 --- /dev/null +++ b/packages/stripe/src/api/index.ts @@ -0,0 +1,30 @@ +import type { AxiosInstance } from "axios"; + +import { create } from "axios"; + +import type { StripeConfig } from "../types"; + +export const getAxiosClient = async ( + apiBaseUrl: string, + config?: StripeConfig, +): Promise => { + if (config?.axiosClient) { + return config.axiosClient(apiBaseUrl); + } + + try { + const { axiosClient } = await import("@prefabs.tech/react-user"); + + return axiosClient(apiBaseUrl); + } catch { + return create({ + baseURL: apiBaseUrl, + headers: { + "Content-Type": "application/json", + }, + withCredentials: true, + }); + } +}; + +export { checkoutSession, getStatus } from "./payment"; diff --git a/packages/stripe/src/api/payment/index.ts b/packages/stripe/src/api/payment/index.ts new file mode 100644 index 000000000..1f340bda0 --- /dev/null +++ b/packages/stripe/src/api/payment/index.ts @@ -0,0 +1,37 @@ +import type { CheckoutSessionPayload, StripeConfig } from "../../types"; + +import { getAxiosClient } from ".."; +import { API_PATH_CHECKOUT_SESSION, API_PATH_STATUS } from "../../constants"; + +export const checkoutSession = async ( + payload: CheckoutSessionPayload, + apiBaseUrl: string, + config?: StripeConfig, +) => { + const path = config?.apiRoutes?.checkoutSession || API_PATH_CHECKOUT_SESSION; + + const client = await getAxiosClient(apiBaseUrl, config); + const response = await client.post(path, payload); + + if ("error" in response.data) { + throw new Error(response.data); + } + + const redirectUrl = response.data.url as string; + window.location.href = redirectUrl; + + return response.data; +}; + +export const getStatus = async (apiBaseUrl: string, config?: StripeConfig) => { + const path = config?.apiRoutes?.status || API_PATH_STATUS; + + const client = await getAxiosClient(apiBaseUrl, config); + const response = await client.get(path); + + if ("error" in response.data) { + throw new Error(response.data); + } + + return response.data; +}; diff --git a/packages/stripe/src/assets/css/cancelled-page.css b/packages/stripe/src/assets/css/cancelled-page.css new file mode 100644 index 000000000..ac5523e59 --- /dev/null +++ b/packages/stripe/src/assets/css/cancelled-page.css @@ -0,0 +1,95 @@ +.cancelled-page[data-centered="true"] { + --_page-max-width: 30rem; +} + +.cancelled-page .dz-card { + animation: fadeInUp 0.6s ease-out; + display: flex; + flex-direction: column; + gap: 1.5rem; + max-width: 30rem; + padding: 2rem 1.5rem; + text-align: center; + width: 100%; +} + +.cancelled-page .crossmark { + margin: 0 auto 0.5rem; + stroke-width: 2; + width: 5rem; +} + +.cancelled-page .crossmark-circle { + animation: stroke 0.6s cubic-bezier(0.65, 0, 0.45, 1) forwards; + stroke: #f44336; + stroke-dasharray: 166; + stroke-dashoffset: 166; + stroke-width: 2; +} + +.cancelled-page .crossmark-line { + animation: stroke 0.3s cubic-bezier(0.65, 0, 0.45, 1) 0.6s forwards; + stroke: var(--dz-danger-color, #dc3545); + stroke-dasharray: 28; + stroke-dashoffset: 28; + stroke-linecap: round; + stroke-width: 3; + transform-origin: 50% 50%; +} + +.cancelled-page h1.title { + color: var(--dz-secondary-color, #475569); + font-size: 1.5rem; + font-weight: 700; + margin-block: 1rem; +} + +.cancelled-page .message { + color: var(--dz-secondary-color, #475569);; + display: flex; + flex-direction: column; + font-size: 1rem; + gap: 0.5rem; + line-height: 1.5; + margin-block: auto; +} + +.cancelled-page .message p { + margin: 0; +} + +.cancelled-page button { + min-width: 12rem; +} + +@keyframes stroke { + 100% { + stroke-dashoffset: 0; + } +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(30px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (width >= 576px) { + .cancelled-page .dz-card { + padding: 3rem 2rem; + } + + .cancelled-page .crossmark { + width: 7rem; + } + + .cancelled-page h1.title { + font-size: 2rem; + } +} diff --git a/packages/stripe/src/assets/css/index.css b/packages/stripe/src/assets/css/index.css new file mode 100644 index 000000000..3012d024b --- /dev/null +++ b/packages/stripe/src/assets/css/index.css @@ -0,0 +1,2 @@ +@import url("./cancelled-page.css"); +@import url("./success-page.css"); diff --git a/packages/stripe/src/assets/css/success-page.css b/packages/stripe/src/assets/css/success-page.css new file mode 100644 index 000000000..12acb7945 --- /dev/null +++ b/packages/stripe/src/assets/css/success-page.css @@ -0,0 +1,106 @@ +.success-page[data-centered="true"] { + --_page-max-width: 30rem; +} + +.success-page .dz-card { + animation: fadeInUp 0.6s ease-out; + display: flex; + flex-direction: column; + gap: 1.5rem; + max-width: 30rem; + padding: 2rem 1.5rem; + text-align: center; + width: 100%; +} + +.success-page .checkmark { + margin: 0 auto 0.5rem; + stroke-width: 2; + width: 5rem; +} + +.success-page .checkmark-circle { + animation: stroke 0.6s cubic-bezier(0.65, 0, 0.45, 1) forwards; + stroke: var(--dz-success-color, #22c55e); + stroke-dasharray: 166; + stroke-dashoffset: 166; + stroke-width: 2; +} + +.success-page .checkmark-check { + animation: stroke 0.3s cubic-bezier(0.65, 0, 0.45, 1) 0.6s forwards; + stroke: var(--dz-success-color, #22c55e); + stroke-dasharray: 48; + stroke-dashoffset: 48; + stroke-linecap: round; + stroke-width: 3; + transform-origin: 50% 50%; +} + +.success-page h1.title { + color: var(--dz-secondary-color, #475569);; + font-size: 1.5rem; + font-weight: 700; + margin-block: 1rem; +} + +.success-page p.subtitle { + color: #4caf50; + font-size: 1.125rem; + font-weight: 600; + margin: 0; +} + +.success-page .message { + color: #666; + display: flex; + flex-direction: column; + font-size: 1rem; + gap: 0.5rem; + line-height: 1.5; + margin-block: auto; +} + +.success-page .message p { + margin: 0; +} + +.success-page button { + min-width: 12rem; +} + +@keyframes stroke { + 100% { + stroke-dashoffset: 0; + } +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(30px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (width >= 576px) { + .success-page .card { + padding: 3rem 2rem; + } + + .success-page .checkmark { + width: 7rem; + } + + .success-page h1.title { + font-size: 2rem; + } + + .success-page p.subtitle { + font-size: 1.25rem; + } +} diff --git a/packages/stripe/src/components/CancelledPage.tsx b/packages/stripe/src/components/CancelledPage.tsx new file mode 100644 index 000000000..fa1c39ae7 --- /dev/null +++ b/packages/stripe/src/components/CancelledPage.tsx @@ -0,0 +1,78 @@ +import type { ReactNode } from "react"; + +import { useTranslation } from "@prefabs.tech/react-i18n"; +import { Button, Card, LoadingPage, Page } from "@prefabs.tech/react-ui"; + +import { useBackNavigation } from "../hooks/useBackNavigation"; + +interface CancelledPageProperties { + actions?: ReactNode; + children?: ReactNode; + loading?: boolean; + onBack?: () => void; + subtitle?: ReactNode; + title?: ReactNode; +} + +const CancelledPage: React.FC = ({ + actions, + children, + loading = false, + onBack, + subtitle, + title, +}) => { + const { t } = useTranslation("stripe"); + const { handleBack } = useBackNavigation(onBack); + + return ( + + {!loading && ( + + + + + + + + {title ||

{t("payment.cancelled.heading")}

} + + {subtitle} + + {children || ( +
+

{t("payment.cancelled.message")}

+
+ )} + + {actions || ( +