Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 87 additions & 20 deletions src/components/MarkerPopup/LocationDetails.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import PropTypes from 'prop-types';
import styled from 'styled-components';
import ExploreIcon from '@mui/icons-material/Explore';
import ReportProblemOutlinedIcon from '@mui/icons-material/ReportProblemOutlined';
import ShareIcon from '@mui/icons-material/Share';
import { useTranslation } from 'react-i18next';
import { isMobile } from 'react-device-detect';
import { buttonStyleSmall } from '../../styles/buttonStyle';
import { getContentAsString, mapCustomTypeToReactComponent } from './mapCustomTypeToReactComponent';
import { ReportProblemForm } from './ReportProblemForm';
import { toast } from '../../utils/toast';

import React, { useState } from 'react';

Expand Down Expand Up @@ -59,6 +61,24 @@ const CTAContainer = styled.div`
margin: 8px;
`;

const ActionButton = styled.button`
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
color: #888;
font-size: 11px;
transition: color 0.2s;
background: none;
border: none;
padding: 0;

&:hover,
&:focus {
color: ${props => props.$hoverColor};
}
`;

/**
* Checks if a value is a custom object type (not an array or null).
*
Expand Down Expand Up @@ -205,6 +225,65 @@ LocationDetails.propTypes = {
}).isRequired,
};

/**
* Button component that shares or copies a link to the current location.
* On mobile, tries the Web Share API first, falling back to clipboard copy.
* On desktop, copies the URL to clipboard and shows a toast notification.
*
* @param {Object} props - Component props
* @param {Object} props.place - Location data object
* @param {Object} props.place.metadata - Metadata object
* @param {string} props.place.metadata.uuid - Unique identifier for the location
* @returns {React.ReactElement} Button element for sharing the location
*/
const ShareLocationButton = ({ place }) => {
const { t } = useTranslation();

const copyToClipboard = async url => {
if (!navigator.clipboard) {
toast.error(t('linkCopyFailed'));
return;
}
try {
await navigator.clipboard.writeText(url);
toast.success(t('linkCopied'));
} catch {
toast.error(t('linkCopyFailed'));
}
};

const handleShare = async () => {
const shareUrl = `${globalThis.location.origin}${globalThis.location.pathname}?locationId=${place.metadata.uuid}`;

if (navigator.share) {
try {
await navigator.share({ url: shareUrl });
} catch (err) {
if (err.name !== 'AbortError') {
await copyToClipboard(shareUrl);
}
}
} else {
await copyToClipboard(shareUrl);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

return (
<ActionButton type="button" onClick={handleShare} $hoverColor="#1976d2">
<ShareIcon style={{ fontSize: 14 }} />
<span>{t('shareLocation')}</span>
</ActionButton>
);
};

ShareLocationButton.propTypes = {
place: PropTypes.shape({
metadata: PropTypes.shape({
uuid: PropTypes.string.isRequired,
}).isRequired,
}).isRequired,
};

/**
* Main component that wraps location details with additional features.
* Includes location details, optional navigation button (mobile only), and report problem form.
Expand Down Expand Up @@ -241,33 +320,21 @@ export const LocationDetailsBox = ({ place }) => {
{isMobile && <NavigateMeButton place={place} />}
</div>

<button
type="button"
onClick={toggleForm}
<div
style={{
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
justifyContent: 'flex-end',
gap: '4px',
color: '#888',
fontSize: '11px',
marginTop: '8px',
marginBottom: '5px',
transition: 'color 0.2s',
background: 'none',
border: 'none',
padding: 0,
width: '100%',
}}
onMouseOver={e => (e.currentTarget.style.color = '#d32f2f')}
onFocus={e => (e.currentTarget.style.color = '#d32f2f')}
onMouseOut={e => (e.currentTarget.style.color = '#888')}
onBlur={e => (e.currentTarget.style.color = '#888')}
>
<ReportProblemOutlinedIcon style={{ fontSize: 14 }} />
<span>{t('ReportIssueButton')}</span>
</button>
<ShareLocationButton place={place} />
<ActionButton type="button" onClick={toggleForm} $hoverColor="#d32f2f">
<ReportProblemOutlinedIcon style={{ fontSize: 14 }} />
<span>{t('ReportIssueButton')}</span>
</ActionButton>
</div>
{showForm && <ReportProblemForm placeId={place.metadata.uuid} />}
</React.Fragment>
);
Expand Down
5 changes: 4 additions & 1 deletion src/locales/en/map.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,8 @@
"locationServicesDisabled": "Location services are disabled. Please enable them to use this feature.",
"locationBannerMessage": "Enable location to see nearby points and suggest new ones.",
"locationBannerEnable": "Enable",
"locationBannerDismiss": "Dismiss"
"locationBannerDismiss": "Dismiss",
"shareLocation": "share",
"linkCopied": "Link copied to clipboard",
"linkCopyFailed": "Failed to copy link"
}
5 changes: 4 additions & 1 deletion src/locales/pl/map.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,8 @@
"locationServicesDisabled": "Usługi lokalizacji są wyłączone. Włącz je, aby korzystać z tej funkcji.",
"locationBannerMessage": "Włącz lokalizację, aby zobaczyć pobliskie punkty i dodawać nowe.",
"locationBannerEnable": "Włącz",
"locationBannerDismiss": "Zamknij"
"locationBannerDismiss": "Zamknij",
"shareLocation": "udostępnij",
"linkCopied": "Link skopiowany do schowka",
"linkCopyFailed": "Nie udało się skopiować linku"
}
5 changes: 4 additions & 1 deletion src/locales/ua/map.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,8 @@
"locationServicesDisabled": "Служби визначення місцезнаходження вимкнено. Будь ласка, увімкніть їх, щоб скористатися цією функцією.",
"locationBannerMessage": "Увімкніть геолокацію, щоб бачити найближчі точки та додавати нові.",
"locationBannerEnable": "Увімкнути",
"locationBannerDismiss": "Закрити"
"locationBannerDismiss": "Закрити",
"shareLocation": "поділитися",
"linkCopied": "Посилання скопійовано",
"linkCopyFailed": "Не вдалося скопіювати посилання"
}
39 changes: 38 additions & 1 deletion tests/MarkerPopup/LocationDetailsBox.test.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React from 'react';
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { LocationDetailsBox } from '../../src/components/MarkerPopup/LocationDetails';
import { toast } from '../../src/utils/toast';

const correctMarkerData = {
title: 'Most Grunwaldzki',
Expand Down Expand Up @@ -95,3 +96,39 @@ describe('should render marker popup correctly', () => {
});
});
});

jest.mock('../../src/utils/toast', () => ({
toast: {
success: jest.fn(),
error: jest.fn(),
info: jest.fn(),
},
}));

describe('share button', () => {
beforeEach(() => {
jest.restoreAllMocks();
render(<LocationDetailsBox place={correctMarkerData} />);
});

it('should render share button in the popup', () => {
expect(screen.getByText('share')).toBeInTheDocument();
});

it('should copy share URL to clipboard when clicked', () => {
const writeTextMock = jest.fn().mockResolvedValue(undefined);
Object.assign(navigator, {
clipboard: { writeText: writeTextMock },
share: undefined,
});

fireEvent.click(screen.getByText('share'));

return waitFor(() => {
expect(writeTextMock).toHaveBeenCalledWith(
expect.stringContaining(`?locationId=${correctMarkerData.metadata.uuid}`),
);
expect(toast.success).toHaveBeenCalledWith('Link copied to clipboard');
});
});
});