create-shopify-firebase-app 1.0.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.
@@ -0,0 +1,116 @@
1
+ import { Router, Request, Response } from "express";
2
+ import fetch from "node-fetch";
3
+ import { verifySessionToken } from "./verify-token";
4
+ import { getAccessToken } from "./auth";
5
+
6
+ export const adminApiRouter = Router();
7
+
8
+ // All admin routes require session token verification
9
+ adminApiRouter.use(verifySessionToken);
10
+
11
+ // ─── Get shop info ───────────────────────────────────────────────────────
12
+ adminApiRouter.get("/shop", async (req: Request, res: Response) => {
13
+ const shop = (req as any).shopDomain;
14
+ const accessToken = await getAccessToken(shop);
15
+
16
+ if (!accessToken) {
17
+ res.status(401).json({ error: "Shop not authenticated" });
18
+ return;
19
+ }
20
+
21
+ try {
22
+ const response = await fetch(
23
+ `https://${shop}/admin/api/2025-04/graphql.json`,
24
+ {
25
+ method: "POST",
26
+ headers: {
27
+ "Content-Type": "application/json",
28
+ "X-Shopify-Access-Token": accessToken,
29
+ },
30
+ body: JSON.stringify({
31
+ query: `{ shop { name email myshopifyDomain plan { displayName } } }`,
32
+ }),
33
+ },
34
+ );
35
+
36
+ const data = (await response.json()) as any;
37
+ res.json({ shop: data.data?.shop });
38
+ } catch (err: any) {
39
+ console.error("Shop info error:", err);
40
+ res.status(500).json({ error: err.message });
41
+ }
42
+ });
43
+
44
+ // ─── Search products (example) ───────────────────────────────────────────
45
+ adminApiRouter.get("/products/search", async (req: Request, res: Response) => {
46
+ const shop = (req as any).shopDomain;
47
+ const query = (req.query.q as string) || "";
48
+ const accessToken = await getAccessToken(shop);
49
+
50
+ if (!accessToken) {
51
+ res.status(401).json({ error: "Shop not authenticated" });
52
+ return;
53
+ }
54
+
55
+ try {
56
+ const response = await fetch(
57
+ `https://${shop}/admin/api/2025-04/graphql.json`,
58
+ {
59
+ method: "POST",
60
+ headers: {
61
+ "Content-Type": "application/json",
62
+ "X-Shopify-Access-Token": accessToken,
63
+ },
64
+ body: JSON.stringify({
65
+ query: `
66
+ query SearchProducts($query: String!) {
67
+ products(first: 10, query: $query) {
68
+ edges {
69
+ node {
70
+ id
71
+ title
72
+ handle
73
+ status
74
+ featuredImage { url }
75
+ variants(first: 1) {
76
+ edges { node { id price } }
77
+ }
78
+ priceRangeV2 {
79
+ minVariantPrice { amount currencyCode }
80
+ }
81
+ }
82
+ }
83
+ }
84
+ }
85
+ `,
86
+ variables: { query },
87
+ }),
88
+ },
89
+ );
90
+
91
+ const data = (await response.json()) as any;
92
+ const products = (data.data?.products?.edges || []).map((edge: any) => ({
93
+ id: edge.node.id,
94
+ title: edge.node.title,
95
+ handle: edge.node.handle,
96
+ status: edge.node.status,
97
+ image: edge.node.featuredImage?.url || null,
98
+ variantId: edge.node.variants.edges[0]?.node.id || null,
99
+ price: edge.node.priceRangeV2?.minVariantPrice?.amount,
100
+ currency: edge.node.priceRangeV2?.minVariantPrice?.currencyCode,
101
+ }));
102
+
103
+ res.json({ products });
104
+ } catch (err: any) {
105
+ console.error("Product search error:", err);
106
+ res.status(500).json({ error: err.message });
107
+ }
108
+ });
109
+
110
+ // ──────────────────────────────────────────────────────────────────────────
111
+ // Add your admin API routes below.
112
+ // All routes are protected by session token verification.
113
+ //
114
+ // const shop = (req as any).shopDomain;
115
+ // const accessToken = await getAccessToken(shop);
116
+ // ──────────────────────────────────────────────────────────────────────────
@@ -0,0 +1,130 @@
1
+ import { Router, Request, Response } from "express";
2
+ import crypto from "crypto";
3
+ import fetch from "node-fetch";
4
+ import { getConfig } from "./config";
5
+ import { db } from "./firebase";
6
+
7
+ export const authRouter = Router();
8
+
9
+ // ─── Step 1: Start OAuth ─────────────────────────────────────────────────
10
+ // Merchant clicks "Install" → redirect to Shopify consent screen.
11
+ authRouter.get("/", (req: Request, res: Response) => {
12
+ const { shop } = req.query;
13
+ if (!shop || typeof shop !== "string") {
14
+ res.status(400).send("Missing shop parameter");
15
+ return;
16
+ }
17
+
18
+ const config = getConfig();
19
+ const nonce = crypto.randomBytes(16).toString("hex");
20
+ const redirectUri = `${config.appUrl}/auth/callback`;
21
+
22
+ // Store nonce for CSRF protection
23
+ db.collection("authNonces").doc(nonce).set({
24
+ shop,
25
+ createdAt: new Date().toISOString(),
26
+ });
27
+
28
+ const authUrl =
29
+ `https://${shop}/admin/oauth/authorize` +
30
+ `?client_id=${config.apiKey}` +
31
+ `&scope=${config.scopes}` +
32
+ `&redirect_uri=${encodeURIComponent(redirectUri)}` +
33
+ `&state=${nonce}`;
34
+
35
+ res.redirect(authUrl);
36
+ });
37
+
38
+ // ─── Step 2: OAuth Callback ──────────────────────────────────────────────
39
+ // Shopify redirects back with code + HMAC. Verify, exchange, store session.
40
+ authRouter.get("/callback", async (req: Request, res: Response) => {
41
+ const { shop, code, hmac, state } = req.query;
42
+
43
+ if (!shop || !code || !hmac) {
44
+ res.status(400).send("Missing required parameters");
45
+ return;
46
+ }
47
+
48
+ const config = getConfig();
49
+
50
+ // Verify HMAC (primary security check — timing-safe)
51
+ const queryParams = { ...req.query };
52
+ delete queryParams.hmac;
53
+ delete queryParams.signature;
54
+ const message = Object.keys(queryParams)
55
+ .sort()
56
+ .map((key) => `${key}=${queryParams[key]}`)
57
+ .join("&");
58
+ const generatedHmac = crypto
59
+ .createHmac("sha256", config.apiSecret)
60
+ .update(message)
61
+ .digest("hex");
62
+
63
+ const hmacBuffer = Buffer.from(hmac as string);
64
+ const generatedBuffer = Buffer.from(generatedHmac);
65
+ if (
66
+ hmacBuffer.length !== generatedBuffer.length ||
67
+ !crypto.timingSafeEqual(generatedBuffer, hmacBuffer)
68
+ ) {
69
+ res.status(403).send("HMAC verification failed");
70
+ return;
71
+ }
72
+
73
+ // Clean up nonce
74
+ if (state) {
75
+ const nonceDoc = await db.collection("authNonces").doc(state as string).get();
76
+ if (nonceDoc.exists) await nonceDoc.ref.delete();
77
+ }
78
+
79
+ // Exchange code for access token
80
+ try {
81
+ const tokenResponse = await fetch(
82
+ `https://${shop}/admin/oauth/access_token`,
83
+ {
84
+ method: "POST",
85
+ headers: { "Content-Type": "application/json" },
86
+ body: JSON.stringify({
87
+ client_id: config.apiKey,
88
+ client_secret: config.apiSecret,
89
+ code,
90
+ }),
91
+ },
92
+ );
93
+
94
+ if (!tokenResponse.ok) {
95
+ const errorText = await tokenResponse.text();
96
+ console.error("Token exchange failed:", errorText);
97
+ res.status(500).send("Token exchange failed");
98
+ return;
99
+ }
100
+
101
+ const tokenData = (await tokenResponse.json()) as {
102
+ access_token: string;
103
+ scope: string;
104
+ };
105
+
106
+ // Store session in Firestore
107
+ await db
108
+ .collection("shopSessions")
109
+ .doc(shop as string)
110
+ .set({
111
+ shop,
112
+ accessToken: tokenData.access_token,
113
+ scope: tokenData.scope,
114
+ installedAt: new Date().toISOString(),
115
+ });
116
+
117
+ console.log(`App installed for shop: ${shop}`);
118
+ res.redirect(`https://${shop}/admin/apps/${config.apiKey}`);
119
+ } catch (err: any) {
120
+ console.error("OAuth error:", err);
121
+ res.status(500).send("OAuth error");
122
+ }
123
+ });
124
+
125
+ // Helper: get stored access token for a shop
126
+ export async function getAccessToken(shop: string): Promise<string | null> {
127
+ const doc = await db.collection("shopSessions").doc(shop).get();
128
+ if (!doc.exists) return null;
129
+ return doc.data()?.accessToken || null;
130
+ }
@@ -0,0 +1,12 @@
1
+ // Centralized config from environment variables.
2
+ // Firebase Functions auto-loads .env files from the functions/ directory.
3
+ // Docs: https://firebase.google.com/docs/functions/config-env
4
+
5
+ export function getConfig() {
6
+ return {
7
+ apiKey: process.env.SHOPIFY_API_KEY || "",
8
+ apiSecret: process.env.SHOPIFY_API_SECRET || "",
9
+ scopes: process.env.SCOPES || "read_products",
10
+ appUrl: process.env.APP_URL || "",
11
+ };
12
+ }
@@ -0,0 +1,10 @@
1
+ import * as admin from "firebase-admin";
2
+
3
+ // Initialize Firebase Admin SDK once.
4
+ // Credentials are auto-detected on Firebase infrastructure.
5
+ // For local dev, use `firebase emulators:start`.
6
+ if (!admin.apps.length) {
7
+ admin.initializeApp();
8
+ }
9
+
10
+ export const db = admin.firestore();
@@ -0,0 +1,40 @@
1
+ import * as functions from "firebase-functions";
2
+ import "./firebase"; // Initialize Firebase first — must be before other imports
3
+ import express from "express";
4
+ import cors from "cors";
5
+ import { authRouter } from "./auth";
6
+ import { adminApiRouter } from "./admin-api";
7
+ import { proxyRouter } from "./proxy";
8
+ import { webhookRouter } from "./webhooks";
9
+
10
+ const expressApp = express();
11
+ expressApp.use(cors({ origin: true }));
12
+
13
+ // Capture raw body for webhook HMAC verification.
14
+ // Shopify signs the raw request body — we need it before JSON parsing.
15
+ expressApp.use(
16
+ express.json({
17
+ limit: "2mb",
18
+ verify: (req: any, _res, buf) => {
19
+ req.rawBody = buf.toString("utf8");
20
+ },
21
+ }),
22
+ );
23
+ expressApp.use(express.urlencoded({ extended: true }));
24
+
25
+ // ─── Routes ────────────────────────────────────────────────────────────────
26
+ expressApp.use("/auth", authRouter); // OAuth install + callback
27
+ expressApp.use("/api", adminApiRouter); // Admin dashboard API (JWT auth)
28
+ expressApp.use("/proxy", proxyRouter); // App Proxy routes (HMAC auth)
29
+ expressApp.use("/webhooks", webhookRouter); // Webhook handlers
30
+
31
+ // Health check
32
+ expressApp.get("/", (_req, res) => {
33
+ res.json({ status: "ok", timestamp: new Date().toISOString() });
34
+ });
35
+
36
+ // Export as a single Cloud Function.
37
+ // Firebase Hosting rewrites (firebase.json) forward requests here.
38
+ export const app = functions
39
+ .runWith({ timeoutSeconds: 60, memory: "256MB" })
40
+ .https.onRequest(expressApp);
@@ -0,0 +1,50 @@
1
+ import { Router, Request, Response } from "express";
2
+ import crypto from "crypto";
3
+ import { getConfig } from "./config";
4
+
5
+ export const proxyRouter = Router();
6
+
7
+ // ─── Verify Shopify App Proxy Signature ──────────────────────────────────
8
+ // Docs: https://shopify.dev/docs/apps/build/online-store/app-proxies
9
+ function verifyProxySignature(query: Record<string, any>): boolean {
10
+ const config = getConfig();
11
+ const signature = query.signature;
12
+ if (!signature) return false;
13
+
14
+ const params = { ...query };
15
+ delete params.signature;
16
+
17
+ // App proxy concatenates without & (different from OAuth HMAC)
18
+ const message = Object.keys(params)
19
+ .sort()
20
+ .map((key) => `${key}=${params[key]}`)
21
+ .join("");
22
+
23
+ const generated = crypto
24
+ .createHmac("sha256", config.apiSecret)
25
+ .update(message)
26
+ .digest("hex");
27
+
28
+ const sigBuffer = Buffer.from(signature);
29
+ const genBuffer = Buffer.from(generated);
30
+ if (sigBuffer.length !== genBuffer.length) return false;
31
+ return crypto.timingSafeEqual(genBuffer, sigBuffer);
32
+ }
33
+
34
+ // ─── Example storefront endpoint ─────────────────────────────────────────
35
+ // Accessible at: https://your-store.myshopify.com/apps/{subpath}/hello
36
+ proxyRouter.get("/hello", (req: Request, res: Response) => {
37
+ if (!verifyProxySignature(req.query as Record<string, any>)) {
38
+ res.status(403).json({ error: "Invalid signature" });
39
+ return;
40
+ }
41
+
42
+ const shop = req.query.shop as string;
43
+ res.json({ message: `Hello from the app proxy! Shop: ${shop}` });
44
+ });
45
+
46
+ // ──────────────────────────────────────────────────────────────────────────
47
+ // Add storefront-facing routes below.
48
+ // Always verify the proxy signature first.
49
+ // Enable App Proxy in shopify.app.toml.
50
+ // ──────────────────────────────────────────────────────────────────────────
@@ -0,0 +1,55 @@
1
+ import { Request, Response, NextFunction } from "express";
2
+ import jwt from "jsonwebtoken";
3
+ import { getConfig } from "./config";
4
+
5
+ // App Bridge session token payload
6
+ // Docs: https://shopify.dev/docs/apps/build/authentication/session-tokens
7
+ interface SessionTokenPayload {
8
+ iss: string; // https://{shop}.myshopify.com/admin
9
+ dest: string; // https://{shop}.myshopify.com
10
+ aud: string; // API key
11
+ sub: string; // User ID
12
+ exp: number;
13
+ nbf: number;
14
+ iat: number;
15
+ jti: string;
16
+ sid: string;
17
+ }
18
+
19
+ // Middleware: verify App Bridge session token.
20
+ // The embedded dashboard sends Authorization: Bearer <jwt> with every request.
21
+ export function verifySessionToken(
22
+ req: Request,
23
+ res: Response,
24
+ next: NextFunction,
25
+ ): void {
26
+ const authHeader = req.headers.authorization;
27
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
28
+ res.status(401).json({ error: "Missing Authorization header" });
29
+ return;
30
+ }
31
+
32
+ const token = authHeader.split(" ")[1];
33
+ const config = getConfig();
34
+
35
+ try {
36
+ const decoded = jwt.verify(token, config.apiSecret, {
37
+ algorithms: ["HS256"],
38
+ }) as SessionTokenPayload;
39
+
40
+ if (decoded.aud !== config.apiKey) {
41
+ res.status(403).json({ error: "Token audience mismatch" });
42
+ return;
43
+ }
44
+
45
+ // Extract shop from issuer URL
46
+ const issUrl = new URL(decoded.iss);
47
+ (req as any).shopDomain = issUrl.hostname;
48
+ (req as any).sessionToken = decoded;
49
+
50
+ next();
51
+ } catch (err) {
52
+ console.error("Session token verification failed:", err);
53
+ res.status(401).json({ error: "Invalid session token" });
54
+ }
55
+ }
@@ -0,0 +1,77 @@
1
+ import { Router, Request, Response } from "express";
2
+ import crypto from "crypto";
3
+ import { getConfig } from "./config";
4
+ import { db } from "./firebase";
5
+
6
+ export const webhookRouter = Router();
7
+
8
+ // ─── Verify Shopify Webhook HMAC ─────────────────────────────────────────
9
+ function verifyWebhookHmac(rawBody: string, hmacHeader: string): boolean {
10
+ const config = getConfig();
11
+ const hash = crypto
12
+ .createHmac("sha256", config.apiSecret)
13
+ .update(rawBody, "utf8")
14
+ .digest("base64");
15
+
16
+ try {
17
+ return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(hmacHeader));
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+
23
+ // ─── Webhook Handler ─────────────────────────────────────────────────────
24
+ // All topics route to this single POST endpoint.
25
+ // Topic is identified via X-Shopify-Topic header.
26
+ // IMPORTANT: Respond 200 within 5 seconds. Do heavy work async.
27
+ webhookRouter.post("/", async (req: Request, res: Response) => {
28
+ const hmac = req.headers["x-shopify-hmac-sha256"] as string;
29
+ const topic = req.headers["x-shopify-topic"] as string;
30
+ const shop = req.headers["x-shopify-shop-domain"] as string;
31
+
32
+ // Verify HMAC
33
+ const rawBody = (req as any).rawBody;
34
+ if (rawBody && hmac && !verifyWebhookHmac(rawBody, hmac)) {
35
+ console.error("Webhook HMAC verification failed");
36
+ res.status(401).send("Unauthorized");
37
+ return;
38
+ }
39
+
40
+ console.log(`Webhook: ${topic} from ${shop}`);
41
+
42
+ switch (topic) {
43
+ // ── App lifecycle ──────────────────────────────────────────────────
44
+ case "app/uninstalled": {
45
+ await db.collection("shopSessions").doc(shop).delete();
46
+ console.log(`Session cleaned up for ${shop}`);
47
+ break;
48
+ }
49
+
50
+ // ── GDPR mandatory webhooks (required for App Store) ───────────────
51
+ case "customers/data_request": {
52
+ // Customer requested their data. Export within 30 days if you store any.
53
+ console.log(`Customer data request: ${shop}`);
54
+ // TODO: implement if you store customer data
55
+ break;
56
+ }
57
+
58
+ case "customers/redact": {
59
+ // Customer requested deletion. Delete within 30 days.
60
+ console.log(`Customer redact: ${shop}`);
61
+ // TODO: implement if you store customer data
62
+ break;
63
+ }
64
+
65
+ case "shop/redact": {
66
+ // 48h after uninstall. Delete ALL shop data.
67
+ console.log(`Shop redact: ${shop}`);
68
+ // TODO: delete all data for this shop from Firestore
69
+ break;
70
+ }
71
+
72
+ default:
73
+ console.log(`Unhandled webhook: ${topic}`);
74
+ }
75
+
76
+ res.status(200).send("OK");
77
+ });
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "module": "commonjs",
4
+ "noImplicitReturns": true,
5
+ "noUnusedLocals": false,
6
+ "outDir": "lib",
7
+ "sourceMap": true,
8
+ "strict": true,
9
+ "target": "es2020",
10
+ "esModuleInterop": true,
11
+ "resolveJsonModule": true
12
+ },
13
+ "compileOnSave": true,
14
+ "include": ["src"]
15
+ }
@@ -0,0 +1,33 @@
1
+ # Dependencies
2
+ node_modules/
3
+
4
+ # Firebase
5
+ .firebase/
6
+ .firebaserc
7
+ firebase-debug.log
8
+ firestore-debug.log
9
+ ui-debug.log
10
+
11
+ # TypeScript build
12
+ functions/lib/
13
+
14
+ # Secrets
15
+ .env
16
+ functions/.env
17
+ .runtimeconfig.json
18
+ functions/.runtimeconfig.json
19
+
20
+ # IDE
21
+ .vscode/
22
+ .idea/
23
+ *.swp
24
+
25
+ # OS
26
+ .DS_Store
27
+ Thumbs.db
28
+
29
+ # Shopify CLI
30
+ .shopify/
31
+
32
+ # Claude Code
33
+ .claude/
@@ -0,0 +1,38 @@
1
+ # Shopify App Configuration
2
+ # Docs: https://shopify.dev/docs/apps/tools/cli/configuration
3
+
4
+ name = "{{APP_NAME}}"
5
+ client_id = "{{API_KEY}}"
6
+ application_url = "{{APP_URL}}"
7
+ embedded = true
8
+
9
+ [access_scopes]
10
+ scopes = "{{SCOPES}}"
11
+
12
+ [auth]
13
+ redirect_urls = [
14
+ "{{APP_URL}}/auth/callback"
15
+ ]
16
+
17
+ [webhooks]
18
+ api_version = "2025-04"
19
+
20
+ # App lifecycle
21
+ [[webhooks.subscriptions]]
22
+ topics = [ "app/uninstalled" ]
23
+ uri = "{{APP_URL}}/webhooks"
24
+
25
+ # GDPR mandatory webhooks (handled automatically by Shopify via compliance endpoints)
26
+ [webhooks.privacy_compliance]
27
+ customer_deletion_url = "{{APP_URL}}/webhooks"
28
+ customer_data_request_url = "{{APP_URL}}/webhooks"
29
+ shop_deletion_url = "{{APP_URL}}/webhooks"
30
+
31
+ # Uncomment to enable App Proxy (storefront-facing routes)
32
+ # [app_proxy]
33
+ # url = "{{APP_URL}}/proxy"
34
+ # subpath = "myapp"
35
+ # prefix = "apps"
36
+
37
+ [pos]
38
+ embedded = false
@@ -0,0 +1,57 @@
1
+ /* Shopify-like admin styles for embedded app dashboard */
2
+
3
+ * { margin: 0; padding: 0; box-sizing: border-box; }
4
+
5
+ body {
6
+ font-family: -apple-system, BlinkMacSystemFont, "San Francisco", "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
7
+ font-size: 14px; line-height: 1.5; color: #1a1a1a; background: #f6f6f7;
8
+ }
9
+
10
+ .app-page { max-width: 960px; margin: 0 auto; padding: 20px 16px; }
11
+ .page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
12
+ .page-header h1 { font-size: 20px; font-weight: 600; }
13
+
14
+ .card {
15
+ background: #fff; border: 1px solid #e1e3e5; border-radius: 12px;
16
+ padding: 20px; margin-bottom: 16px; box-shadow: 0 1px 2px rgba(0,0,0,0.05);
17
+ }
18
+ .card h2 { font-size: 16px; font-weight: 600; margin-bottom: 12px; }
19
+
20
+ .btn {
21
+ display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px;
22
+ border-radius: 8px; font-size: 13px; font-weight: 500; cursor: pointer;
23
+ border: 1px solid #c9cccf; background: #fff; color: #1a1a1a;
24
+ transition: background 0.15s;
25
+ }
26
+ .btn:hover { background: #f6f6f7; }
27
+ .btn-primary { background: #008060; color: #fff; border-color: #008060; }
28
+ .btn-primary:hover { background: #006e52; }
29
+ .btn-danger { color: #d72c0d; border-color: #d72c0d; }
30
+ .btn-danger:hover { background: #fff4f4; }
31
+
32
+ .form-group { margin-bottom: 16px; }
33
+ .form-group label { display: block; font-size: 13px; font-weight: 500; margin-bottom: 4px; color: #6d7175; }
34
+ .form-group input, .form-group select, .form-group textarea {
35
+ width: 100%; padding: 8px 12px; border: 1px solid #c9cccf; border-radius: 8px; font-size: 14px;
36
+ }
37
+ .form-group input:focus, .form-group select:focus { outline: none; border-color: #008060; box-shadow: 0 0 0 1px #008060; }
38
+
39
+ .empty-state { text-align: center; padding: 40px 20px; }
40
+ .empty-state h3 { font-size: 16px; margin-bottom: 8px; }
41
+ .empty-state p { color: #6d7175; margin-bottom: 16px; }
42
+
43
+ .loading-overlay { display: flex; align-items: center; justify-content: center; gap: 10px; padding: 60px 20px; color: #6d7175; }
44
+ .spinner { width: 20px; height: 20px; border: 2px solid #e1e3e5; border-top-color: #008060; border-radius: 50%; animation: spin 0.6s linear infinite; }
45
+ @keyframes spin { to { transform: rotate(360deg); } }
46
+
47
+ .toast {
48
+ position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%) translateY(100px);
49
+ background: #1a1a1a; color: #fff; padding: 10px 20px; border-radius: 8px;
50
+ font-size: 13px; transition: transform 0.3s ease; z-index: 9999;
51
+ }
52
+ .toast.show { transform: translateX(-50%) translateY(0); }
53
+
54
+ .badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 12px; font-weight: 500; }
55
+ .badge-success { background: #e4f5e9; color: #00713a; }
56
+ .badge-warning { background: #fff8e6; color: #8a6500; }
57
+ .badge-error { background: #fff4f4; color: #d72c0d; }