Skip to content

Commit 76605d3

Browse files
authored
Merge pull request #650 from acelaya-forks/feature/qr-code-logo
Allow logo to be set in QR codes
2 parents 908432e + 2abe4fb commit 76605d3

File tree

7 files changed

+2457
-62
lines changed

7 files changed

+2457
-62
lines changed

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
66

7+
## [Unreleased]
8+
### Added
9+
* [#641](https://github.com/shlinkio/shlink-web-component/issues/641) Allow custom logos to be selected for the short URL QR code.
10+
11+
### Changed
12+
* *Nothing*
13+
14+
### Deprecated
15+
* *Nothing*
16+
17+
### Removed
18+
* *Nothing*
19+
20+
### Fixed
21+
* *Nothing*
22+
23+
724
## [0.13.2] - 2025-04-12
825
### Added
926
* *Nothing*

src/short-urls/helpers/QrCodeModal.tsx

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
1-
import { faClone } from '@fortawesome/free-regular-svg-icons';
2-
import { faCheck, faFileDownload as downloadIcon } from '@fortawesome/free-solid-svg-icons';
1+
import { faClone, faImage } from '@fortawesome/free-regular-svg-icons';
2+
import { faCheck, faFileDownload as downloadIcon, faXmark } from '@fortawesome/free-solid-svg-icons';
33
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
44
import { useTimeoutToggle } from '@shlinkio/shlink-frontend-kit';
5-
import type { FC } from 'react';
5+
import type { ChangeEvent, FC } from 'react';
66
import { useCallback , useRef , useState } from 'react';
77
import { ExternalLink } from 'react-external-link';
88
import { Button, Modal, ModalBody, ModalHeader } from 'reactstrap';
99
import { ColorInput } from '../../utils/components/ColorInput';
1010
import type { QrRef } from '../../utils/components/QrCode';
1111
import { QrCode } from '../../utils/components/QrCode';
12-
import { useFeature } from '../../utils/features';
1312
import { copyToClipboard } from '../../utils/helpers/clipboard';
1413
import type { QrCodeFormat, QrDrawType, QrErrorCorrection } from '../../utils/helpers/qrCodes';
1514
import type { ShortUrlModalProps } from '../data';
@@ -32,8 +31,18 @@ export const QrCodeModal: FC<QrCodeModalProps> = (
3231
const [color, setColor] = useState('#000000');
3332
const [bgColor, setBgColor] = useState('#ffffff');
3433
const [format, setFormat] = useState<QrCodeFormat>('png');
34+
const [logo, setLogo] = useState<{ url: string; name: string }>();
3535

36-
const qrCodeColorsSupported = useFeature('qrCodeColors');
36+
const logoInputRef = useRef<HTMLInputElement>(null);
37+
const onSelectLogo = useCallback((e: ChangeEvent<HTMLInputElement>) => {
38+
const file = e.target.files?.[0];
39+
if (file) {
40+
setLogo({
41+
url: URL.createObjectURL(new Blob([file], { type: file.type })),
42+
name: file.name,
43+
});
44+
}
45+
}, []);
3746

3847
const qrCodeRef = useRef<QrRef>(null);
3948
const downloadQrCode = useCallback(
@@ -46,14 +55,24 @@ export const QrCodeModal: FC<QrCodeModalProps> = (
4655
return copyToClipboard({ text: uri, onCopy: toggleCopied });
4756
}, [format, toggleCopied]);
4857

58+
const resetOptions = useCallback(() => {
59+
setSize(300);
60+
setMargin(0);
61+
setErrorCorrection('L');
62+
setColor('#000000');
63+
setBgColor('#ffffff');
64+
setFormat('png');
65+
setLogo(undefined);
66+
}, []);
67+
4968
return (
50-
<Modal isOpen={isOpen} toggle={toggle} centered size="lg">
69+
<Modal isOpen={isOpen} toggle={toggle} centered size="lg" onClosed={resetOptions}>
5170
<ModalHeader toggle={toggle}>
5271
QR code for <ExternalLink href={shortUrl}>{shortUrl}</ExternalLink>
5372
</ModalHeader>
5473
<ModalBody className="d-flex flex-column-reverse flex-lg-row gap-3">
5574
<div className="flex-grow-1 d-flex align-items-center justify-content-around qr-code-modal__qr-code">
56-
<div className="d-flex flex-column gap-1" data-testid="qr-code-container">
75+
<div className="d-flex flex-column gap-1 align-items-center" data-testid="qr-code-container">
5776
<QrCode
5877
ref={qrCodeRef}
5978
data={shortUrl}
@@ -62,6 +81,7 @@ export const QrCodeModal: FC<QrCodeModalProps> = (
6281
errorCorrection={errorCorrection}
6382
color={color}
6483
bgColor={bgColor}
84+
logo={logo?.url}
6585
drawType={qrDrawType}
6686
/>
6787
<div className="text-center fst-italic">Preview ({size + margin}x{size + margin})</div>
@@ -85,13 +105,41 @@ export const QrCodeModal: FC<QrCodeModalProps> = (
85105
max={100}
86106
/>
87107
<QrErrorCorrectionDropdown errorCorrection={errorCorrection} onChange={setErrorCorrection} />
108+
<ColorInput name="color" color={color} onChange={setColor} />
109+
<ColorInput name="background" color={bgColor} onChange={setBgColor} />
88110

89-
{qrCodeColorsSupported && (
111+
{!logo && (
90112
<>
91-
<ColorInput name="color" color={color} onChange={setColor} />
92-
<ColorInput name="background" color={bgColor} onChange={setBgColor} />
113+
<Button
114+
outline
115+
className="d-flex align-items-center gap-1"
116+
onClick={() => logoInputRef.current?.click()}
117+
>
118+
<FontAwesomeIcon icon={faImage} />
119+
Select logo
120+
</Button>
121+
<input
122+
ref={logoInputRef}
123+
type="file"
124+
accept="image/*"
125+
aria-hidden
126+
tabIndex={-1}
127+
className="d-none"
128+
onChange={onSelectLogo}
129+
data-testid="logo-input"
130+
/>
93131
</>
94132
)}
133+
{logo && (
134+
<Button
135+
outline
136+
className="d-flex align-items-center gap-1"
137+
onClick={() => setLogo(undefined)}
138+
>
139+
<FontAwesomeIcon icon={faXmark} />
140+
<div className="text-truncate">Clear logo ({logo.name})</div>
141+
</Button>
142+
)}
95143

96144
<div className="my-auto">
97145
<hr className="my-2" />

src/utils/components/QrCode.tsx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import QRCodeStyling from 'qr-code-styling';
22
import { forwardRef, useCallback , useEffect , useImperativeHandle , useRef } from 'react';
3-
import type { QrCodeFormat, QrCodeOptions, QrDrawType } from '../helpers/qrCodes';
3+
import type { QrCodeFormat, QrCodeOptions, QrDrawType, QrErrorCorrection } from '../helpers/qrCodes';
44

55
export type QrCodeProps = Omit<QrCodeOptions, 'format'> & {
66
data: string;
@@ -12,6 +12,17 @@ export type QrRef = {
1212
getDataUri: (format: QrCodeFormat) => Promise<string>;
1313
};
1414

15+
function errorCorrectionToLogoSize(errorCorrection: QrErrorCorrection): number {
16+
switch (errorCorrection) {
17+
case 'L':
18+
return 1;
19+
case 'M':
20+
return 0.5;
21+
default:
22+
return 0.3;
23+
}
24+
}
25+
1526
export const QrCode = forwardRef<QrRef, QrCodeProps>(({
1627
data,
1728
color = '#000000',
@@ -20,6 +31,7 @@ export const QrCode = forwardRef<QrRef, QrCodeProps>(({
2031
errorCorrection = 'L',
2132
size = 300,
2233
drawType = 'canvas',
34+
logo,
2335
}, ref) => {
2436
const containerRef = useRef<HTMLDivElement>(null);
2537
const qrCodeRef = useRef(new QRCodeStyling());
@@ -48,7 +60,7 @@ export const QrCode = forwardRef<QrRef, QrCodeProps>(({
4860
});
4961
}), []);
5062

51-
// Expose the download method via provided ref
63+
// Expose the download and getDataUri methods via provided ref
5264
useImperativeHandle(ref, () => ({ download, getDataUri }), [download, getDataUri]);
5365

5466
useEffect(() => {
@@ -66,8 +78,13 @@ export const QrCode = forwardRef<QrRef, QrCodeProps>(({
6678
dotsOptions: { color },
6779
backgroundOptions: { color: bgColor },
6880
qrOptions: { errorCorrectionLevel: errorCorrection },
81+
imageOptions: {
82+
margin: 5,
83+
imageSize: errorCorrectionToLogoSize(errorCorrection),
84+
},
85+
image: logo,
6986
});
70-
}, [bgColor, color, data, drawType, errorCorrection, margin, size]);
87+
}, [bgColor, color, data, drawType, errorCorrection, logo, margin, size]);
7188

7289
return <div ref={containerRef} />;
7390
});

src/utils/features.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ const supportedFeatures = {
77
orphanVisitsDeletion: { minVersion: '3.7.0' },
88
deviceLongUrls: { maxVersion: '3.*.*' },
99
shortUrlRedirectRules: { minVersion: '4.0.0' },
10-
qrCodeColors: { minVersion: '4.0.0' },
1110
urlValidation: { maxVersion: '3.*.*' },
1211
ipRedirectCondition: { minVersion: '4.2.0' },
1312
geolocationRedirectCondition: { minVersion: '4.3.0' },
@@ -28,7 +27,6 @@ const getFeaturesForVersion = (serverVersion: SemVerOrLatest): Record<Feature, b
2827
shortUrlVisitsDeletion: isFeatureEnabledForVersion('shortUrlVisitsDeletion', serverVersion),
2928
orphanVisitsDeletion: isFeatureEnabledForVersion('orphanVisitsDeletion', serverVersion),
3029
shortUrlRedirectRules: isFeatureEnabledForVersion('shortUrlRedirectRules', serverVersion),
31-
qrCodeColors: isFeatureEnabledForVersion('qrCodeColors', serverVersion),
3230
urlValidation: isFeatureEnabledForVersion('urlValidation', serverVersion),
3331
ipRedirectCondition: isFeatureEnabledForVersion('ipRedirectCondition', serverVersion),
3432
geolocationRedirectCondition: isFeatureEnabledForVersion('geolocationRedirectCondition', serverVersion),

src/utils/helpers/qrCodes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ export type QrCodeOptions = {
1515
errorCorrection?: QrErrorCorrection;
1616
color?: string;
1717
bgColor?: string;
18+
logo?: string;
1819
};

test/short-urls/helpers/QrCodeModal.test.tsx

Lines changed: 42 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,21 @@ import { fireEvent, screen, waitFor } from '@testing-library/react';
22
import type { UserEvent } from '@testing-library/user-event';
33
import { fromPartial } from '@total-typescript/shoehorn';
44
import { QrCodeModal } from '../../../src/short-urls/helpers/QrCodeModal';
5-
import { FeaturesProvider } from '../../../src/utils/features';
65
import { checkAccessibility } from '../../__helpers__/accessibility';
76
import { renderWithEvents } from '../../__helpers__/setUpTest';
87

98
describe('<QrCodeModal />', () => {
109
const shortUrl = 'https://s.test/abc123';
11-
const setUp = ({ qrCodeColors = false }: { qrCodeColors?: boolean } = {}) => renderWithEvents(
12-
<FeaturesProvider value={fromPartial({ qrCodeColors })}>
13-
<QrCodeModal
14-
isOpen
15-
shortUrl={fromPartial({ shortUrl })}
16-
toggle={() => {}}
17-
qrDrawType="svg" // Render as SVG so that we can test certain functionalities via snapshots
18-
/>
19-
</FeaturesProvider>,
10+
const setUp = () => renderWithEvents(
11+
<QrCodeModal
12+
isOpen
13+
shortUrl={fromPartial({ shortUrl })}
14+
toggle={() => {}}
15+
qrDrawType="svg" // Render as SVG so that we can test certain functionalities via snapshots
16+
/>,
2017
);
2118

22-
it.each([{ qrCodeColors: false }, { qrCodeColors: true }])(
23-
'passes a11y checks',
24-
({ qrCodeColors }) => checkAccessibility(setUp({ qrCodeColors })),
25-
);
19+
it('passes a11y checks', () => checkAccessibility(setUp()));
2620

2721
it('shows an external link to the URL in the header', () => {
2822
setUp();
@@ -61,26 +55,47 @@ describe('<QrCodeModal />', () => {
6155
fireEvent.change(screen.getByLabelText('background picker'), { target: { value: '#0000ff' } });
6256
},
6357
},
58+
{
59+
// Set custom logo
60+
applyChanges: async (user: UserEvent) => {
61+
const byteCharacters = atob('iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII');
62+
const byteNumbers = Array.from(byteCharacters, (char) => char.charCodeAt(0));
63+
const byteArray = new Uint8Array(byteNumbers);
64+
const logo = new File([byteArray], 'logo.png', { type: 'image/svg' });
65+
66+
await user.upload(screen.getByTestId('logo-input'), [logo]);
67+
},
68+
},
6469
])('displays an image with expected configuration', async ({ applyChanges }) => {
65-
const { user } = setUp({ qrCodeColors: true });
70+
const { user } = setUp();
6671

6772
await applyChanges(user);
6873
expect(screen.getByTestId('qr-code-container')).toMatchSnapshot();
6974
});
7075

7176
it.each([
72-
{ qrCodeColors: false },
73-
{ qrCodeColors: true },
74-
])('shows color controls only if feature is supported', ({ qrCodeColors }) => {
75-
setUp({ qrCodeColors });
76-
77-
if (qrCodeColors) {
78-
expect(screen.getByLabelText('color')).toBeInTheDocument();
79-
expect(screen.getByLabelText('background')).toBeInTheDocument();
80-
} else {
81-
expect(screen.queryByLabelText('color')).not.toBeInTheDocument();
82-
expect(screen.queryByLabelText('background')).not.toBeInTheDocument();
83-
}
77+
'logo.png',
78+
'some-image.svg',
79+
'whatever.jpg',
80+
])('allows logo to be seat and cleared', async (logoName) => {
81+
const logo = new File([''], logoName, { type: 'image/svg' });
82+
const { user } = setUp();
83+
84+
// At first, we can select a logo
85+
expect(screen.getByRole('button', { name: 'Select logo' })).toBeInTheDocument();
86+
expect(screen.queryByRole('button', { name: /^Clear logo/ })).not.toBeInTheDocument();
87+
88+
await user.upload(screen.getByTestId('logo-input'), [logo]);
89+
90+
// Once a logo has been selected, we can clear it
91+
expect(screen.queryByRole('button', { name: 'Select logo' })).not.toBeInTheDocument();
92+
expect(screen.getByRole('button', { name: /^Clear logo/ })).toHaveTextContent(`Clear logo (${logoName})`);
93+
94+
await user.click(screen.getByRole('button', { name: /^Clear logo/ }));
95+
96+
// After clearing previous logo, we can select a new one
97+
expect(screen.getByRole('button', { name: 'Select logo' })).toBeInTheDocument();
98+
expect(screen.queryByRole('button', { name: /^Clear logo/ })).not.toBeInTheDocument();
8499
});
85100

86101
// FIXME This test needs a real browser

0 commit comments

Comments
 (0)