Skip to content

Commit 69132c2

Browse files
authored
Merge pull request #972 from evershopcommerce/release213
feat: Move logo, favicon, social banner and GA4 to admin setting
2 parents 7d4489c + 851f64e commit 69132c2

12 files changed

Lines changed: 522 additions & 77 deletions

File tree

changelog.md

668 Bytes

v2.1.3

Breaking Changes

  • Removed the legacy themeConfig.logo configuration. The store logo is now uploaded in the admin (Store Setting, Branding section) instead of being set in config.json. Stores that previously configured themeConfig.logo in config.json must re-upload their logo in the admin after upgrading.

v2.1.2 (2026-04-03)

Breaking Changes

  • Update payment status for PayPal orders

    • authorized -> paypal_authorized
    • captured -> paypal_captured
  • Update payment status for Stripe orders

    • authorized -> stripe_authorized
    • captured -> stripe_captured
    • refunded -> stripe_refunded
    • partial_refunded -> stripe_partial_refunded
    • failed -> stripe_failed

Breaking Changes

  • Remove some components in favor of using shadcn/ui components
    • components/common/Button.jsx
    • components/common/modal/Modal.jsx
    • components/common/modal/useModal.jsx
    • components/admin/Badge.jsx
    • components/admin/Card.jsx
    • components/admin/Circle.jsx
    • components/admin/Dot.jsx
  • Update Tailwind CSS from v3 to v4,
  • Email service refactored to use a single email service instance. Please upgrade your sendgrid and resend extensions to the latest version.

Breaking changes

This release includes a major breaking change due to the TypeScript migration. Please refer to the updated documentation for guidance on adapting your setup.

Breaking Changes

From this version, we require Node.js 18.17.0 or higher to run the EverShop application.

Breaking Changes

FeaturedProducts.jsx component has been removed

The FeaturedProducts.jsx component has been removed from the store frontend. From now on, you can use the Widgets feature to create and display featured products on your store.

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import { Image, ImageUploader } from '@components/admin/ImageUploader.js';
2+
import { InputField } from '@components/common/form/InputField.js';
3+
import { _ } from '@evershop/evershop/lib/locale/translate/_';
4+
import React, { useEffect, useState } from 'react';
5+
import { useFormContext } from 'react-hook-form';
6+
import uniqid from 'uniqid';
7+
8+
type PreviewType = 'logo' | 'favicon' | 'social';
9+
10+
/**
11+
* Build a URL for the on-the-fly image endpoint (`GET /images`), which resizes
12+
* + re-encodes the asset and serves it with an immutable cache header. Passing
13+
* the stored (relative) `/assets/...` path lets the processor read the file
14+
* locally instead of re-fetching it over HTTP.
15+
*/
16+
function imageEndpoint(
17+
src: string,
18+
params: { w: number; h?: number; q?: number; f?: 'webp' | 'jpeg' | 'png' }
19+
): string {
20+
const query = new URLSearchParams({
21+
src,
22+
w: String(params.w),
23+
q: String(params.q ?? 80),
24+
f: params.f ?? 'webp'
25+
});
26+
if (params.h) {
27+
query.set('h', String(params.h));
28+
}
29+
return `/images?${query.toString()}`;
30+
}
31+
32+
function AssetPreview({ url, type }: { url: string; type: PreviewType }) {
33+
if (type === 'favicon') {
34+
// Render at the exact sizes the storefront emits so contrast / legibility
35+
// problems at 16px are obvious before saving.
36+
return (
37+
<div className="mt-3 flex items-end gap-4">
38+
{[16, 32, 48].map((size) => (
39+
<div key={size} className="flex flex-col items-center gap-1">
40+
<img
41+
src={imageEndpoint(url, { w: size, h: size, q: 90, f: 'png' })}
42+
width={size}
43+
height={size}
44+
alt=""
45+
/>
46+
<span className="text-[11px] text-muted-foreground">{size}px</span>
47+
</div>
48+
))}
49+
</div>
50+
);
51+
}
52+
if (type === 'social') {
53+
// Mirrors the actual og:image render (width-only, jpeg) so the admin sees
54+
// the true crop/ratio a crawler will get.
55+
return (
56+
<div className="mt-3 max-w-sm overflow-hidden rounded-lg border border-border">
57+
<img
58+
src={imageEndpoint(url, { w: 1200, q: 80, f: 'jpeg' })}
59+
className="block w-full"
60+
alt={_('Social sharing preview')}
61+
/>
62+
</div>
63+
);
64+
}
65+
// logo — show it on light and dark so a logo that disappears on one theme is
66+
// caught immediately.
67+
return (
68+
<div className="mt-3 flex gap-3">
69+
<div className="flex h-12 items-center rounded-md border border-border bg-white px-3">
70+
<img
71+
src={imageEndpoint(url, { w: 240, q: 85, f: 'webp' })}
72+
alt=""
73+
className="max-h-7 w-auto"
74+
/>
75+
</div>
76+
<div className="flex h-12 items-center rounded-md border border-border bg-neutral-900 px-3">
77+
<img
78+
src={imageEndpoint(url, { w: 240, q: 85, f: 'webp' })}
79+
alt=""
80+
className="max-h-7 w-auto"
81+
/>
82+
</div>
83+
</div>
84+
);
85+
}
86+
87+
export interface BrandAssetUploaderProps {
88+
/** Form field / setting key, e.g. `logo`, `favicon`, `socialSharingImage`. */
89+
name: string;
90+
/** Current saved value (a `/assets/...` URL) or empty string. */
91+
defaultValue?: string;
92+
/** Media subfolder the file lands in; a unique token is appended per mount. */
93+
folder?: string;
94+
/** Which contextual preview to render under the uploader. */
95+
previewType?: PreviewType;
96+
/**
97+
* When set, the asset's real intrinsic pixel size is measured on upload and
98+
* written to these form fields. Used for the logo — whose aspect ratio is
99+
* arbitrary — so the storefront can size it without distortion or layout
100+
* shift. Omit for fixed-ratio assets (favicon, social image).
101+
*/
102+
widthName?: string;
103+
heightName?: string;
104+
defaultWidth?: string;
105+
defaultHeight?: string;
106+
}
107+
108+
/**
109+
* Single-asset image upload for store branding (logo / favicon / social image).
110+
* Wraps the shared {@link ImageUploader} in single mode, mirrors the uploaded
111+
* URL into a hidden form field so it saves with the rest of the store settings,
112+
* and renders a contextual preview built from the `/images` endpoint.
113+
*/
114+
export function BrandAssetUploader({
115+
name,
116+
defaultValue,
117+
folder = 'branding',
118+
previewType,
119+
widthName,
120+
heightName,
121+
defaultWidth,
122+
defaultHeight
123+
}: BrandAssetUploaderProps) {
124+
const [image, setImage] = useState<Image | undefined>(
125+
defaultValue ? { uuid: name, url: defaultValue } : undefined
126+
);
127+
const { setValue } = useFormContext();
128+
// A fresh folder per mount so replacing an asset never overwrites the old
129+
// file: the image endpoint caches by source path with an immutable header, so
130+
// a reused filename would keep serving the previous render.
131+
const [targetPath] = useState(() => `${folder}/${uniqid()}`);
132+
133+
useEffect(() => {
134+
setValue(name, image?.url || '');
135+
}, [image, name, setValue]);
136+
137+
// Measure the real intrinsic size of the asset (logo only) so the storefront
138+
// sizes it correctly. `window.Image` — the bare `Image` is the type imported
139+
// above, not the DOM constructor.
140+
useEffect(() => {
141+
if (!widthName || !heightName) {
142+
return;
143+
}
144+
if (!image?.url) {
145+
setValue(widthName, '');
146+
setValue(heightName, '');
147+
return;
148+
}
149+
const probe = new window.Image();
150+
probe.onload = () => {
151+
setValue(widthName, String(probe.naturalWidth));
152+
setValue(heightName, String(probe.naturalHeight));
153+
};
154+
probe.src = image.url;
155+
}, [image, widthName, heightName, setValue]);
156+
157+
return (
158+
<div>
159+
{/* The shared uploader is a 100%-width square; in this wide settings
160+
column that balloons, so cap it to a compact thumbnail. */}
161+
<div className="w-40">
162+
<ImageUploader
163+
isMultiple={false}
164+
allowDelete
165+
currentImages={image ? [image] : []}
166+
onUpload={(images) => {
167+
if (images.length > 0) {
168+
setImage(images[0]);
169+
}
170+
}}
171+
onDelete={() => setImage(undefined)}
172+
targetPath={targetPath}
173+
/>
174+
</div>
175+
<InputField type="hidden" name={name} defaultValue={defaultValue ?? ''} />
176+
{widthName && (
177+
<InputField
178+
type="hidden"
179+
name={widthName}
180+
defaultValue={defaultWidth ?? ''}
181+
/>
182+
)}
183+
{heightName && (
184+
<InputField
185+
type="hidden"
186+
name={heightName}
187+
defaultValue={defaultHeight ?? ''}
188+
/>
189+
)}
190+
{previewType && image?.url ? (
191+
<AssetPreview url={image.url} type={previewType} />
192+
) : null}
193+
</div>
194+
);
195+
}

packages/evershop/src/lib/mail/emailHelper.ts

Lines changed: 15 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -287,27 +287,21 @@ export async function buildEmailBodyFromTemplate(
287287
* @returns The prepared email data with store information.
288288
*/
289289
async function prepareData(data: EmailData): Promise<EmailData> {
290-
const logoConfig = getConfig('themeConfig.logo');
291-
let logo;
292-
if (logoConfig) {
293-
const url = logoConfig.src || '';
294-
// check if url is absolute
295-
if (url && !/^https?:\/\//i.test(url)) {
296-
logo = {
297-
src: `${getBaseUrl()}${url}`,
298-
alt: logoConfig?.alt || '',
299-
height: logoConfig?.height ? String(logoConfig.height) : undefined,
300-
width: logoConfig?.width ? String(logoConfig.width) : undefined
301-
};
302-
} else {
303-
logo = {
304-
src: url,
305-
alt: logoConfig?.alt || '',
306-
height: logoConfig?.height ? String(logoConfig.height) : undefined,
307-
width: logoConfig?.width ? String(logoConfig.width) : undefined
308-
};
309-
}
310-
}
290+
// The logo is an admin setting (Store Setting → Branding). The email needs an
291+
// ABSOLUTE, email-safe image, so it is served through the /images endpoint as a
292+
// sized PNG (WebP/AVIF are unreliable in Outlook and older mail clients). When
293+
// unset, `logo` stays undefined and each template's {{#if storeInfo.logo}} skips it.
294+
const logoSetting = await getSetting<string>('logo', '');
295+
const logo = logoSetting
296+
? {
297+
src: `${getBaseUrl()}/images?src=${encodeURIComponent(
298+
logoSetting
299+
)}&w=360&q=85&f=png`,
300+
alt: await getSetting('storeName', 'Evershop'),
301+
// Display width only (2× source for crisp retina); height stays auto.
302+
width: '180'
303+
}
304+
: undefined;
311305
const addressCountry = await getSetting('storeCountry', 'US');
312306
const addressProvince = await getSetting('storeProvince', '');
313307
const addressCity = await getSetting('storeCity', '');

packages/evershop/src/lib/util/getConfig.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -97,12 +97,6 @@ type ConfigStructure = {
9797
};
9898
};
9999
themeConfig: {
100-
logo: {
101-
alt: string | undefined;
102-
src: string | undefined;
103-
width: number | undefined;
104-
height: number | undefined;
105-
};
106100
headTags: {
107101
links: any[];
108102
metas: any[];
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import React from 'react';
2+
3+
interface GoogleAnalyticsProps {
4+
setting: {
5+
gaMeasurementId?: string | null;
6+
};
7+
}
8+
9+
// GA4 Measurement IDs look like `G-XXXXXXXXXX`. Validating before injection also
10+
// guarantees the id is safe to interpolate into the inline bootstrap script.
11+
const GA4_ID_PATTERN = /^G-[A-Z0-9]+$/i;
12+
13+
/**
14+
* Loads Google Analytics 4 when a Measurement ID is configured in the store
15+
* settings. Injection happens client-side in an effect — not as SSR'd <script>
16+
* tags — so it runs identically in dev (client render) and prod (SSR), and the
17+
* one-time guard keeps a re-mount / re-hydration from double-counting page_view.
18+
*/
19+
export default function GoogleAnalytics({
20+
setting: { gaMeasurementId }
21+
}: GoogleAnalyticsProps) {
22+
React.useEffect(() => {
23+
if (!gaMeasurementId || !GA4_ID_PATTERN.test(gaMeasurementId)) {
24+
return;
25+
}
26+
const w = window as unknown as { __evershopGaLoaded?: string };
27+
if (w.__evershopGaLoaded === gaMeasurementId) {
28+
return;
29+
}
30+
w.__evershopGaLoaded = gaMeasurementId;
31+
32+
const loader = document.createElement('script');
33+
loader.async = true;
34+
loader.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(
35+
gaMeasurementId
36+
)}`;
37+
document.head.appendChild(loader);
38+
39+
// Google's canonical bootstrap, verbatim (pushes the `arguments` object).
40+
const inline = document.createElement('script');
41+
inline.text =
42+
`window.dataLayer=window.dataLayer||[];` +
43+
`function gtag(){dataLayer.push(arguments);}` +
44+
`gtag('js',new Date());` +
45+
`gtag('config','${gaMeasurementId}');`;
46+
document.head.appendChild(inline);
47+
}, [gaMeasurementId]);
48+
49+
return null;
50+
}
51+
52+
export const layout = {
53+
areaId: 'head',
54+
sortOrder: 1
55+
};
56+
57+
export const query = `
58+
query Query {
59+
setting {
60+
gaMeasurementId
61+
}
62+
}
63+
`;

packages/evershop/src/modules/base/pages/frontStore/all/HeadTags.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,28 @@ export default function HeadTags({
6969
});
7070
}, []);
7171

72+
// `.ico` / `.svg` are served as-is (the image endpoint resizes via sharp,
73+
// which handles neither). A raster favicon (PNG/JPG/WebP) is downscaled to the
74+
// standard tab sizes plus a 180px Apple touch icon, all through `/images`.
75+
const renderFavicon = () => {
76+
if (!favicon) {
77+
return null;
78+
}
79+
const lower = favicon.toLowerCase().split('?')[0];
80+
if (lower.endsWith('.ico') || lower.endsWith('.svg')) {
81+
return <link rel="icon" href={favicon} />;
82+
}
83+
const icon = (size: number) =>
84+
`/images?src=${encodeURIComponent(favicon)}&w=${size}&h=${size}&q=90&f=png`;
85+
return (
86+
<>
87+
<link rel="icon" type="image/png" sizes="32x32" href={icon(32)} />
88+
<link rel="icon" type="image/png" sizes="16x16" href={icon(16)} />
89+
<link rel="apple-touch-icon" sizes="180x180" href={icon(180)} />
90+
</>
91+
);
92+
};
93+
7294
return (
7395
<>
7496
<title>{title}</title>
@@ -83,7 +105,7 @@ export default function HeadTags({
83105
{scripts.map((script, index) => (
84106
<script key={index} {...script} />
85107
))}
86-
{favicon && <link rel="icon" href={favicon} />}
108+
{renderFavicon()}
87109
{keywords && keywords.length > 0 && (
88110
<meta name="keywords" content={keywords.join(', ')} />
89111
)}

0 commit comments

Comments
 (0)