@hobenakicoffee/libraries 0.0.10 → 0.0.12

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/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@hobenakicoffee/libraries",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
4
4
  "type": "module",
5
5
  "types": "src/index.ts",
6
6
  "exports": {
7
7
  ".": "./src/index.ts",
8
- "./constants": "./src/constants/index.ts"
8
+ "./constants": "./src/constants/index.ts",
9
+ "./utils": "./src/utils/index.ts"
9
10
  },
10
11
  "files": [
11
12
  "src",
@@ -30,5 +31,8 @@
30
31
  },
31
32
  "publishConfig": {
32
33
  "access": "public"
34
+ },
35
+ "dependencies": {
36
+ "sonner": "^2.0.7"
33
37
  }
34
38
  }
@@ -0,0 +1,30 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { formatAmount, formatSignedAmount } from "./format-amount";
3
+
4
+ describe("formatAmount", () => {
5
+ test("formats absolute number with currency symbol", () => {
6
+ const expected = new Intl.NumberFormat(undefined, {
7
+ maximumFractionDigits: 0,
8
+ }).format(1234);
9
+
10
+ expect(formatAmount(-1234)).toBe(`৳${expected}`);
11
+ });
12
+ });
13
+
14
+ describe("formatSignedAmount", () => {
15
+ test("uses minus sign for debit", () => {
16
+ const expected = new Intl.NumberFormat(undefined, {
17
+ maximumFractionDigits: 0,
18
+ }).format(2000);
19
+
20
+ expect(formatSignedAmount(2000, "debit")).toBe(`- ৳${expected}`);
21
+ });
22
+
23
+ test("uses plus sign for credit and absolute value", () => {
24
+ const expected = new Intl.NumberFormat(undefined, {
25
+ maximumFractionDigits: 0,
26
+ }).format(2000);
27
+
28
+ expect(formatSignedAmount(-2000, "credit")).toBe(`+ ৳${expected}`);
29
+ });
30
+ });
@@ -0,0 +1,19 @@
1
+ export function formatAmount(value: number) {
2
+ const formatted = new Intl.NumberFormat(undefined, {
3
+ maximumFractionDigits: 0,
4
+ }).format(Math.abs(value));
5
+
6
+ return `৳${formatted}`;
7
+ }
8
+
9
+ export function formatSignedAmount(
10
+ value: number,
11
+ direction: "debit" | "credit"
12
+ ) {
13
+ const formatted = new Intl.NumberFormat(undefined, {
14
+ maximumFractionDigits: 0,
15
+ }).format(Math.abs(value));
16
+
17
+ const sign = direction === "debit" ? "-" : "+";
18
+ return `${sign} ৳${formatted}`;
19
+ }
@@ -0,0 +1,19 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { formatDate } from "./format-date";
3
+
4
+ describe("formatDate", () => {
5
+ test("returns dash for invalid date", () => {
6
+ expect(formatDate("not-a-date")).toBe("-");
7
+ });
8
+
9
+ test("formats a valid date", () => {
10
+ const input = "2026-02-13T00:00:00.000Z";
11
+ const expected = new Date(input).toLocaleDateString(undefined, {
12
+ month: "short",
13
+ day: "numeric",
14
+ year: "numeric",
15
+ });
16
+
17
+ expect(formatDate(input)).toBe(expected);
18
+ });
19
+ });
@@ -0,0 +1,13 @@
1
+ export function formatDate(value: string) {
2
+ const date = new Date(value);
3
+
4
+ if (Number.isNaN(date.getTime())) {
5
+ return "-";
6
+ }
7
+
8
+ return date.toLocaleDateString(undefined, {
9
+ month: "short",
10
+ day: "numeric",
11
+ year: "numeric",
12
+ });
13
+ }
@@ -0,0 +1,36 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { formatMetadataKey, formatToPlainText } from "./format-plain-text";
3
+
4
+ describe("formatToPlainText", () => {
5
+ test("returns empty string for null and undefined", () => {
6
+ expect(formatToPlainText(null)).toBe("");
7
+ expect(formatToPlainText(undefined)).toBe("");
8
+ });
9
+
10
+ test("formats booleans to Yes/No by default", () => {
11
+ expect(formatToPlainText(true)).toBe("Yes");
12
+ expect(formatToPlainText(false)).toBe("No");
13
+ });
14
+
15
+ test("can keep raw boolean text", () => {
16
+ expect(formatToPlainText(true, { formatBooleans: false })).toBe("true");
17
+ });
18
+
19
+ test("truncates long strings with ellipsis", () => {
20
+ expect(formatToPlainText("abcdef", { maxStringLength: 5 })).toBe("ab...");
21
+ });
22
+
23
+ test("stringifies objects and arrays", () => {
24
+ expect(formatToPlainText({ a: 1 })).toBe(JSON.stringify({ a: 1 }, null, 2));
25
+ expect(formatToPlainText(["x", "y"])).toBe(
26
+ JSON.stringify(["x", "y"], null, 2),
27
+ );
28
+ });
29
+ });
30
+
31
+ describe("formatMetadataKey", () => {
32
+ test("formats camelCase and snake_case keys", () => {
33
+ expect(formatMetadataKey("supporterName")).toBe("Supporter Name");
34
+ expect(formatMetadataKey("is_monthly")).toBe("Is monthly");
35
+ });
36
+ });
@@ -0,0 +1,58 @@
1
+ const CAPITALIZE_REGEX = /([A-Z])/g;
2
+ const SPACE_REPLACE_REGEX = /_/g;
3
+ const FIRST_LETTER_REGEX = /^\w/;
4
+
5
+ export function formatToPlainText(
6
+ value: unknown,
7
+ options?: {
8
+ formatBooleans?: boolean;
9
+ preserveNumbers?: boolean;
10
+ maxStringLength?: number;
11
+ }
12
+ ): string {
13
+ const formatBooleans = options?.formatBooleans ?? true;
14
+ const preserveNumbers = options?.preserveNumbers ?? true;
15
+ const maxStringLength = options?.maxStringLength ?? 100;
16
+
17
+ // Handle null/undefined values
18
+ if (value === null || value === undefined) {
19
+ return "";
20
+ }
21
+
22
+ // Format based on type
23
+ switch (typeof value) {
24
+ case "boolean":
25
+ return formatBooleans ? (value ? "Yes" : "No") : String(value);
26
+
27
+ case "number":
28
+ return preserveNumbers ? value.toString() : value.toString();
29
+
30
+ case "string": {
31
+ const stringValue = value as string;
32
+ return maxStringLength && stringValue.length > maxStringLength
33
+ ? `${stringValue.substring(0, maxStringLength - 3)}...`
34
+ : stringValue;
35
+ }
36
+
37
+ case "object":
38
+ if (Array.isArray(value)) {
39
+ return JSON.stringify(value, null, 2);
40
+ }
41
+ return JSON.stringify(value, null, 2);
42
+
43
+ default:
44
+ return String(value);
45
+ }
46
+ }
47
+
48
+ function capitalizeKey(keyToFormat: string): string {
49
+ return keyToFormat
50
+ .replace(CAPITALIZE_REGEX, " $1") // "supporterName" → "supporter Name"
51
+ .replace(SPACE_REPLACE_REGEX, " ") // "is_monthly" → "is monthly"
52
+ .replace(FIRST_LETTER_REGEX, (c) => c.toUpperCase()) // "supporter" → "Supporter"
53
+ .trim();
54
+ }
55
+
56
+ export function formatMetadataKey(key: string): string {
57
+ return capitalizeKey(key);
58
+ }
@@ -0,0 +1,32 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { getSocialUrl } from "./get-social-handle";
3
+ import { getUserPageLink } from "./get-user-page-link";
4
+
5
+ describe("getSocialUrl", () => {
6
+ test("returns our platform URL when username is provided", () => {
7
+ expect(getSocialUrl("alice", "x", "ignored")).toBe(
8
+ getUserPageLink("alice"),
9
+ );
10
+ });
11
+
12
+ test("returns # when required fields are missing", () => {
13
+ expect(getSocialUrl(undefined, "x", "")).toBe("#");
14
+ expect(getSocialUrl(undefined, null, "name")).toBe("#");
15
+ });
16
+
17
+ test("builds social URL with sanitized handle", () => {
18
+ expect(getSocialUrl(undefined, "instagram", " @john doe ")).toBe(
19
+ "https://instagram.com/johndoe",
20
+ );
21
+ });
22
+
23
+ test("uses platform-specific format for youtube", () => {
24
+ expect(getSocialUrl(undefined, "youtube", "Jane")).toBe(
25
+ "https://youtube.com/@Jane",
26
+ );
27
+ });
28
+
29
+ test("returns # for unsupported platform", () => {
30
+ expect(getSocialUrl(undefined, "myspace" as any, "Jane")).toBe("#");
31
+ });
32
+ });
@@ -0,0 +1,66 @@
1
+ import type { SupporterPlatform } from "@hobenakicoffee/libraries";
2
+ import { getUserPageLink } from "./get-user-page-link";
3
+
4
+ const patternsToRemove = /^@/;
5
+
6
+ const sanitizeHandle = (value: string) =>
7
+ encodeURIComponent(
8
+ value.trim().replace(patternsToRemove, "").replace(/\s+/g, "")
9
+ );
10
+
11
+ export const getSocialUrl = (
12
+ ourPlatformUsername?: string | null,
13
+ platform?: SupporterPlatform | null,
14
+ supporterName?: string | null
15
+ ) => {
16
+ if (ourPlatformUsername) {
17
+ return getUserPageLink(ourPlatformUsername);
18
+ }
19
+
20
+ if (!(platform && supporterName?.trim())) {
21
+ return "#";
22
+ }
23
+
24
+ const handle = sanitizeHandle(supporterName);
25
+
26
+ switch (platform) {
27
+ case "facebook":
28
+ return `https://facebook.com/${handle}`;
29
+ case "x":
30
+ return `https://x.com/${handle}`;
31
+ case "instagram":
32
+ return `https://instagram.com/${handle}`;
33
+ case "youtube":
34
+ return `https://youtube.com/@${handle}`;
35
+ case "github":
36
+ return `https://github.com/${handle}`;
37
+ case "linkedin":
38
+ return `https://www.linkedin.com/in/${handle}`;
39
+ case "twitch":
40
+ return `https://twitch.tv/${handle}`;
41
+ case "tiktok":
42
+ return `https://www.tiktok.com/@${handle}`;
43
+ case "threads":
44
+ return `https://www.threads.net/@${handle}`;
45
+ case "whatsapp":
46
+ return `https://wa.me/${handle}`;
47
+ case "telegram":
48
+ return `https://t.me/${handle}`;
49
+ case "discord":
50
+ return `https://discord.com/users/${handle}`;
51
+ case "reddit":
52
+ return `https://reddit.com/u/${handle}`;
53
+ case "pinterest":
54
+ return `https://pinterest.com/${handle}`;
55
+ case "medium":
56
+ return `https://medium.com/@${handle}`;
57
+ case "devto":
58
+ return `https://dev.to/${handle}`;
59
+ case "behance":
60
+ return `https://www.behance.net/${handle}`;
61
+ case "dribbble":
62
+ return `https://dribbble.com/${handle}`;
63
+ default:
64
+ return "#";
65
+ }
66
+ };
@@ -0,0 +1,10 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { getUserPageLink } from "./get-user-page-link";
3
+
4
+ describe("getUserPageLink", () => {
5
+ test("builds user page link with sanitized username", () => {
6
+ const result = getUserPageLink(" @john doe ");
7
+
8
+ expect(result.endsWith("/@%40johndoe")).toBe(true);
9
+ });
10
+ });
@@ -0,0 +1,6 @@
1
+ export function getUserPageLink(username: string) {
2
+ const sanitizedUsername = encodeURIComponent(
3
+ username.trim().replace(/\s+/g, ""),
4
+ );
5
+ return `https://hobenakicoffee.com/@${sanitizedUsername}`;
6
+ }
@@ -0,0 +1,11 @@
1
+ export * from "./format-amount";
2
+ export * from "./format-date";
3
+ export * from "./format-plain-text";
4
+ export * from "./get-social-handle";
5
+ export * from "./get-user-page-link";
6
+ export * from "./open-to-new-window";
7
+ export * from "./post-to-facebook";
8
+ export * from "./post-to-instagram";
9
+ export * from "./post-to-linkedin";
10
+ export * from "./post-to-x";
11
+ export * from "./qr-svg-utils";
@@ -0,0 +1,34 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { openInNewWindow } from "./open-to-new-window";
3
+
4
+ describe("openInNewWindow", () => {
5
+ const originalWindow = (globalThis as any).window;
6
+
7
+ afterEach(() => {
8
+ (globalThis as any).window = originalWindow;
9
+ });
10
+
11
+ test("opens url in a new tab", () => {
12
+ const calls: unknown[][] = [];
13
+ (globalThis as any).window = {
14
+ open: (...args: unknown[]) => {
15
+ calls.push(args);
16
+ },
17
+ };
18
+
19
+ openInNewWindow("https://example.com");
20
+
21
+ expect(calls).toHaveLength(1);
22
+ expect(calls[0]).toEqual([
23
+ "https://example.com",
24
+ "_blank",
25
+ "noopener,noreferrer",
26
+ ]);
27
+ });
28
+
29
+ test("does nothing when window is undefined", () => {
30
+ (globalThis as any).window = undefined;
31
+
32
+ expect(() => openInNewWindow("https://example.com")).not.toThrow();
33
+ });
34
+ });
@@ -0,0 +1,5 @@
1
+ export const openInNewWindow = (url: string): void => {
2
+ if (typeof window === "undefined") return;
3
+
4
+ window.open(url, "_blank", "noopener,noreferrer");
5
+ };
@@ -0,0 +1,43 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { shareToFacebook } from "./post-to-facebook";
3
+
4
+ describe("shareToFacebook", () => {
5
+ const originalWindow = (globalThis as any).window;
6
+
7
+ afterEach(() => {
8
+ (globalThis as any).window = originalWindow;
9
+ });
10
+
11
+ test("throws when url is missing", () => {
12
+ expect(() => shareToFacebook({ url: "" })).toThrow(
13
+ "Facebook share requires a URL",
14
+ );
15
+ });
16
+
17
+ test("opens facebook share url with params", () => {
18
+ const calls: unknown[][] = [];
19
+ (globalThis as any).window = {
20
+ open: (...args: unknown[]) => {
21
+ calls.push(args);
22
+ },
23
+ };
24
+
25
+ shareToFacebook({
26
+ url: "https://example.com",
27
+ quote: "Hello",
28
+ hashtag: "#coffee",
29
+ ref: "campaign",
30
+ });
31
+
32
+ const params = new URLSearchParams({ u: "https://example.com" });
33
+ params.append("quote", "Hello");
34
+ params.append("hashtag", "#coffee");
35
+ params.append("ref", "campaign");
36
+
37
+ expect(calls[0]).toEqual([
38
+ `https://www.facebook.com/sharer/sharer.php?${params.toString()}`,
39
+ "_blank",
40
+ "noopener,noreferrer",
41
+ ]);
42
+ });
43
+ });
@@ -0,0 +1,29 @@
1
+ type FacebookShareOptions = {
2
+ url: string; // Required
3
+ quote?: string; // Optional (may be ignored by FB)
4
+ hashtag?: string; // Only ONE hashtag allowed
5
+ ref?: string; // Channel / campaign reference
6
+ };
7
+
8
+ export function shareToFacebook({
9
+ url,
10
+ quote,
11
+ hashtag,
12
+ ref,
13
+ }: FacebookShareOptions) {
14
+ if (!url) {
15
+ throw new Error("Facebook share requires a URL");
16
+ }
17
+
18
+ const params = new URLSearchParams({
19
+ u: url,
20
+ });
21
+
22
+ if (quote) params.append("quote", quote);
23
+ if (hashtag) params.append("hashtag", hashtag);
24
+ if (ref) params.append("ref", ref);
25
+
26
+ const shareUrl = `https://www.facebook.com/sharer/sharer.php?${params.toString()}`;
27
+
28
+ window.open(shareUrl, "_blank", "noopener,noreferrer");
29
+ }
@@ -0,0 +1,56 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ mock.module("sonner", () => ({
4
+ toast: {
5
+ success: () => undefined,
6
+ error: () => undefined,
7
+ },
8
+ }));
9
+
10
+ describe("shareToInstagram", () => {
11
+ const originalNavigator = (globalThis as any).navigator;
12
+ const originalWindow = (globalThis as any).window;
13
+
14
+ beforeEach(() => {
15
+ (globalThis as any).window = {
16
+ open: () => undefined,
17
+ };
18
+ });
19
+
20
+ afterEach(() => {
21
+ (globalThis as any).navigator = originalNavigator;
22
+ (globalThis as any).window = originalWindow;
23
+ });
24
+
25
+ test("throws when url is missing", async () => {
26
+ const { shareToInstagram } = await import("./post-to-instagram");
27
+
28
+ expect(() => shareToInstagram({ url: "" })).toThrow(
29
+ "Instagram share requires a URL",
30
+ );
31
+ });
32
+
33
+ test("uses Web Share API when available", async () => {
34
+ const shareCalls: unknown[] = [];
35
+
36
+ (globalThis as any).navigator = {
37
+ share: (payload: unknown) => {
38
+ shareCalls.push(payload);
39
+ return Promise.resolve();
40
+ },
41
+ clipboard: {
42
+ writeText: () => Promise.resolve(),
43
+ },
44
+ };
45
+
46
+ const { shareToInstagram } = await import("./post-to-instagram");
47
+
48
+ shareToInstagram({ url: "https://example.com", text: "Look" });
49
+
50
+ expect(shareCalls).toHaveLength(1);
51
+ expect(shareCalls[0]).toEqual({
52
+ title: "Look",
53
+ url: "https://example.com",
54
+ });
55
+ });
56
+ });
@@ -0,0 +1,42 @@
1
+ import { toast } from "sonner";
2
+
3
+ type InstagramShareOptions = {
4
+ url: string; // Required
5
+ text?: string; // Optional text to include
6
+ };
7
+
8
+ export function shareToInstagram({ url, text }: InstagramShareOptions) {
9
+ if (!url) {
10
+ throw new Error("Instagram share requires a URL");
11
+ }
12
+
13
+ // Instagram doesn't have a direct web share URL like Facebook or LinkedIn
14
+ // We'll use the Web Share API if available, otherwise fall back to copying the link
15
+ if (navigator.share) {
16
+ navigator
17
+ .share({
18
+ title: text || "Check this out!",
19
+ url,
20
+ })
21
+ .catch(() => {
22
+ toast.error("Failed to share to Instagram.");
23
+ });
24
+ } else {
25
+ // Fallback: Open Instagram website or copy to clipboard
26
+ // Since Instagram doesn't support direct web sharing, we'll open their homepage
27
+ // Users can manually paste the link in their post
28
+ window.open("https://www.instagram.com/", "_blank", "noopener,noreferrer");
29
+
30
+ // Copy the URL to clipboard for easy pasting
31
+ navigator.clipboard
32
+ .writeText(url)
33
+ .then(() => {
34
+ toast.success(
35
+ "Link copied to clipboard. You can now paste it in your Instagram post."
36
+ );
37
+ })
38
+ .catch(() => {
39
+ toast.error("Failed to copy link to clipboard.");
40
+ });
41
+ }
42
+ }
@@ -0,0 +1,43 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { shareToLinkedIn } from "./post-to-linkedin";
3
+
4
+ describe("shareToLinkedIn", () => {
5
+ const originalWindow = (globalThis as any).window;
6
+
7
+ afterEach(() => {
8
+ (globalThis as any).window = originalWindow;
9
+ });
10
+
11
+ test("throws when url is missing", () => {
12
+ expect(() => shareToLinkedIn({ url: "" })).toThrow(
13
+ "LinkedIn share requires a URL",
14
+ );
15
+ });
16
+
17
+ test("opens linkedin share url with params", () => {
18
+ const calls: unknown[][] = [];
19
+ (globalThis as any).window = {
20
+ open: (...args: unknown[]) => {
21
+ calls.push(args);
22
+ },
23
+ };
24
+
25
+ shareToLinkedIn({
26
+ url: "https://example.com",
27
+ title: "Title",
28
+ summary: "Summary",
29
+ source: "Source",
30
+ });
31
+
32
+ const params = new URLSearchParams({ url: "https://example.com" });
33
+ params.append("title", "Title");
34
+ params.append("summary", "Summary");
35
+ params.append("source", "Source");
36
+
37
+ expect(calls[0]).toEqual([
38
+ `https://www.linkedin.com/sharing/share-offsite/?${params.toString()}`,
39
+ "_blank",
40
+ "noopener,noreferrer",
41
+ ]);
42
+ });
43
+ });
@@ -0,0 +1,29 @@
1
+ type LinkedInShareOptions = {
2
+ url: string; // Required
3
+ title?: string; // Optional title
4
+ summary?: string; // Optional summary/description
5
+ source?: string; // Optional source attribution
6
+ };
7
+
8
+ export function shareToLinkedIn({
9
+ url,
10
+ title,
11
+ summary,
12
+ source,
13
+ }: LinkedInShareOptions) {
14
+ if (!url) {
15
+ throw new Error("LinkedIn share requires a URL");
16
+ }
17
+
18
+ const params = new URLSearchParams({
19
+ url,
20
+ });
21
+
22
+ if (title) params.append("title", title);
23
+ if (summary) params.append("summary", summary);
24
+ if (source) params.append("source", source);
25
+
26
+ const shareUrl = `https://www.linkedin.com/sharing/share-offsite/?${params.toString()}`;
27
+
28
+ window.open(shareUrl, "_blank", "noopener,noreferrer");
29
+ }
@@ -0,0 +1,45 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { shareToX } from "./post-to-x";
3
+
4
+ describe("shareToX", () => {
5
+ const originalWindow = (globalThis as any).window;
6
+
7
+ afterEach(() => {
8
+ (globalThis as any).window = originalWindow;
9
+ });
10
+
11
+ test("throws when text is missing", () => {
12
+ expect(() => shareToX({ text: "" })).toThrow(
13
+ "X share requires text content",
14
+ );
15
+ });
16
+
17
+ test("opens x intent url with params", () => {
18
+ const calls: unknown[][] = [];
19
+ (globalThis as any).window = {
20
+ open: (...args: unknown[]) => {
21
+ calls.push(args);
22
+ },
23
+ };
24
+
25
+ shareToX({
26
+ text: "Hello",
27
+ url: "https://example.com",
28
+ hashtags: "coffee,shop",
29
+ via: "hobenaki",
30
+ related: "friend1,friend2",
31
+ });
32
+
33
+ const params = new URLSearchParams({ text: "Hello" });
34
+ params.append("url", "https://example.com");
35
+ params.append("hashtags", "coffee,shop");
36
+ params.append("via", "hobenaki");
37
+ params.append("related", "friend1,friend2");
38
+
39
+ expect(calls[0]).toEqual([
40
+ `https://twitter.com/intent/tweet?${params.toString()}`,
41
+ "_blank",
42
+ "noopener,noreferrer",
43
+ ]);
44
+ });
45
+ });
@@ -0,0 +1,26 @@
1
+ type XShareOptions = {
2
+ text: string; // Required
3
+ url?: string; // Optional URL to share
4
+ hashtags?: string; // Comma-separated hashtags (without #)
5
+ via?: string; // Twitter username to attribute (without @)
6
+ related?: string; // Comma-separated accounts to recommend
7
+ };
8
+
9
+ export function shareToX({ text, url, hashtags, via, related }: XShareOptions) {
10
+ if (!text) {
11
+ throw new Error("X share requires text content");
12
+ }
13
+
14
+ const params = new URLSearchParams({
15
+ text,
16
+ });
17
+
18
+ if (url) params.append("url", url);
19
+ if (hashtags) params.append("hashtags", hashtags);
20
+ if (via) params.append("via", via);
21
+ if (related) params.append("related", related);
22
+
23
+ const shareUrl = `https://twitter.com/intent/tweet?${params.toString()}`;
24
+
25
+ window.open(shareUrl, "_blank", "noopener,noreferrer");
26
+ }
@@ -0,0 +1,104 @@
1
+ import { afterEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ mock.module("sonner", () => ({
4
+ toast: {
5
+ success: () => undefined,
6
+ error: () => undefined,
7
+ },
8
+ }));
9
+
10
+ describe("downloadQrSvgAsPng", () => {
11
+ const originalImage = (globalThis as any).Image;
12
+
13
+ afterEach(() => {
14
+ (globalThis as any).Image = originalImage;
15
+ });
16
+
17
+ test("calls onError when image loading fails", async () => {
18
+ class BrokenImage {
19
+ public onload: (() => void) | null = null;
20
+ public onerror: (() => void) | null = null;
21
+ public decoding = "async";
22
+
23
+ set src(_value: string) {
24
+ this.onerror?.();
25
+ }
26
+ }
27
+
28
+ (globalThis as any).Image = BrokenImage;
29
+
30
+ const { downloadQrSvgAsPng } = await import("./qr-svg-utils");
31
+
32
+ let failed = false;
33
+ await downloadQrSvgAsPng("<svg></svg>", "test.png", undefined, () => {
34
+ failed = true;
35
+ });
36
+
37
+ expect(failed).toBe(true);
38
+ });
39
+ });
40
+
41
+ describe("printQrSvg", () => {
42
+ const originalDocument = (globalThis as any).document;
43
+ const originalDOMParser = (globalThis as any).DOMParser;
44
+
45
+ afterEach(() => {
46
+ (globalThis as any).document = originalDocument;
47
+ (globalThis as any).DOMParser = originalDOMParser;
48
+ });
49
+
50
+ test("calls onError for invalid SVG markup", async () => {
51
+ const createNode = () => ({
52
+ style: {},
53
+ textContent: "",
54
+ setAttribute: () => undefined,
55
+ append: () => undefined,
56
+ appendChild: () => undefined,
57
+ });
58
+
59
+ const iframeDocument = {
60
+ documentElement: {},
61
+ createElement: () => createNode(),
62
+ replaceChild: () => undefined,
63
+ };
64
+
65
+ const iframe = {
66
+ style: {},
67
+ contentWindow: {
68
+ document: iframeDocument,
69
+ addEventListener: () => undefined,
70
+ focus: () => undefined,
71
+ print: () => undefined,
72
+ },
73
+ setAttribute: () => undefined,
74
+ remove: () => undefined,
75
+ };
76
+
77
+ (globalThis as any).document = {
78
+ createElement: (tagName: string) =>
79
+ tagName === "iframe" ? iframe : createNode(),
80
+ body: {
81
+ append: () => undefined,
82
+ },
83
+ };
84
+
85
+ (globalThis as any).DOMParser = class {
86
+ parseFromString() {
87
+ return {
88
+ documentElement: {
89
+ nodeName: "parsererror",
90
+ },
91
+ };
92
+ }
93
+ };
94
+
95
+ const { printQrSvg } = await import("./qr-svg-utils");
96
+
97
+ let failed = false;
98
+ printQrSvg("<svg", "QR Print", () => {
99
+ failed = true;
100
+ });
101
+
102
+ expect(failed).toBe(true);
103
+ });
104
+ });
@@ -0,0 +1,182 @@
1
+ // Utility functions for QR code SVG download and print
2
+
3
+ import { toast } from "sonner";
4
+
5
+ /**
6
+ * Downloads a QR code SVG as a PNG file.
7
+ * @param svgMarkup - The SVG markup string
8
+ * @param fileName - The file name for the PNG
9
+ * @param onSuccess - Callback for success
10
+ * @param onError - Callback for error
11
+ */
12
+ export async function downloadQrSvgAsPng(
13
+ svgMarkup: string,
14
+ fileName: string,
15
+ onSuccess?: () => void,
16
+ onError?: () => void
17
+ ): Promise<void> {
18
+ try {
19
+ const svgBlob = new Blob([svgMarkup], { type: "image/svg+xml" });
20
+ const svgUrl = URL.createObjectURL(svgBlob);
21
+
22
+ const image = new Image();
23
+ image.decoding = "async";
24
+
25
+ await new Promise<void>((resolve, reject) => {
26
+ image.onload = () => resolve();
27
+ image.onerror = () => reject(new Error("QR image failed to load"));
28
+ image.src = svgUrl;
29
+ });
30
+
31
+ const canvas = document.createElement("canvas");
32
+ const context = canvas.getContext("2d");
33
+ if (!context) {
34
+ URL.revokeObjectURL(svgUrl);
35
+ throw new Error("Canvas context unavailable");
36
+ }
37
+
38
+ const exportScale = 8;
39
+ const maxSize = 2400;
40
+ const scaleFromMaxSize = maxSize / image.width;
41
+ const scale = Math.min(exportScale, scaleFromMaxSize);
42
+
43
+ canvas.width = Math.round(image.width * scale);
44
+ canvas.height = Math.round(image.height * scale);
45
+
46
+ context.fillStyle = "white";
47
+ context.fillRect(0, 0, canvas.width, canvas.height);
48
+ context.imageSmoothingEnabled = false;
49
+ context.drawImage(image, 0, 0, canvas.width, canvas.height);
50
+
51
+ URL.revokeObjectURL(svgUrl);
52
+
53
+ const pngBlob = await new Promise<Blob>((resolve, reject) => {
54
+ canvas.toBlob((blob) => {
55
+ if (!blob) {
56
+ reject(new Error("PNG blob generation failed"));
57
+ return;
58
+ }
59
+ resolve(blob);
60
+ }, "image/png");
61
+ });
62
+
63
+ const pngUrl = URL.createObjectURL(pngBlob);
64
+ const link = document.createElement("a");
65
+ link.href = pngUrl;
66
+ link.download = fileName;
67
+ link.click();
68
+
69
+ setTimeout(() => {
70
+ URL.revokeObjectURL(pngUrl);
71
+ }, 1000);
72
+
73
+ onSuccess?.();
74
+ } catch {
75
+ toast.error("Download failed");
76
+ onError?.();
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Prints a QR code SVG markup.
82
+ * @param svgMarkup - The SVG markup string
83
+ * @param printTitle - The title for the print window
84
+ * @param onError - Callback for error
85
+ */
86
+ export function printQrSvg(
87
+ svgMarkup: string,
88
+ printTitle: string,
89
+ onError?: () => void
90
+ ): void {
91
+ const iframe = document.createElement("iframe");
92
+ iframe.setAttribute("title", printTitle);
93
+ iframe.style.position = "fixed";
94
+ iframe.style.right = "0";
95
+ iframe.style.bottom = "0";
96
+ iframe.style.width = "0";
97
+ iframe.style.height = "0";
98
+ iframe.style.border = "0";
99
+ iframe.style.opacity = "0";
100
+ iframe.style.pointerEvents = "none";
101
+
102
+ document.body.append(iframe);
103
+
104
+ const cleanup = (): void => {
105
+ iframe.remove();
106
+ };
107
+
108
+ const iframeWindow = iframe.contentWindow;
109
+ const iframeDocument = iframeWindow?.document;
110
+
111
+ if (!(iframeWindow && iframeDocument)) {
112
+ cleanup();
113
+ onError?.();
114
+ return;
115
+ }
116
+
117
+ iframeWindow.addEventListener(
118
+ "afterprint",
119
+ () => {
120
+ cleanup();
121
+ },
122
+ { once: true }
123
+ );
124
+
125
+ // Construct document structure using DOM methods
126
+ const html = iframeDocument.createElement("html");
127
+ const head = iframeDocument.createElement("head");
128
+ const body = iframeDocument.createElement("body");
129
+
130
+ const meta = iframeDocument.createElement("meta");
131
+ meta.setAttribute("charset", "utf-8");
132
+
133
+ const title = iframeDocument.createElement("title");
134
+ title.textContent = printTitle;
135
+
136
+ const style = iframeDocument.createElement("style");
137
+ style.textContent = `
138
+ @page { margin: 0; }
139
+ html, body { height: 100%; }
140
+ body {
141
+ margin: 0;
142
+ display: flex;
143
+ align-items: center;
144
+ justify-content: center;
145
+ background: #fff;
146
+ }
147
+ svg { width: 70vmin; height: 70vmin; }
148
+ `;
149
+
150
+ head.append(meta, title, style);
151
+ html.append(head, body);
152
+
153
+ // Replace the existing document element
154
+ iframeDocument.replaceChild(html, iframeDocument.documentElement);
155
+
156
+ // Safely parse and insert SVG
157
+ try {
158
+ const parser = new DOMParser();
159
+ const svgDoc = parser.parseFromString(svgMarkup, "image/svg+xml");
160
+ const svgElement = svgDoc.documentElement;
161
+
162
+ if (svgElement.nodeName === "parsererror") {
163
+ throw new Error("Invalid SVG markup");
164
+ }
165
+
166
+ body.appendChild(svgElement);
167
+ } catch {
168
+ toast.error("Failed to load QR code for printing");
169
+ cleanup();
170
+ onError?.();
171
+ return;
172
+ }
173
+
174
+ try {
175
+ iframeWindow.focus();
176
+ iframeWindow.print();
177
+ } catch {
178
+ toast.error("Printing failed");
179
+ cleanup();
180
+ onError?.();
181
+ }
182
+ }