Initial commit

This commit is contained in:
dchar 2025-07-25 21:05:50 +02:00
commit 593e1928b3
63 changed files with 15824 additions and 0 deletions

10
.dockerignore Normal file
View File

@ -0,0 +1,10 @@
web/server/node_modules
web/client/node_modules
web/client/dist
/node_modules
.gitignore
README.md
shopify.app.toml

32
.gitignore vendored Normal file
View File

@ -0,0 +1,32 @@
# Environment Configuration
.env
# .env.*
# Dependency directory
node_modules
# Test coverage directory
coverage
# Ignore Apple macOS Desktop Services Store
.DS_Store
# Logs
logs
*.log
# ngrok tunnel file
config/tunnel.pid
# vite build output
dist/
# extensions build output
extensions/*/build
# unwanted folders and files
myslider/
# Node library SQLite database
web/server/src/database/database.sqlite
shopify.app.toml

3
.npmrc Normal file
View File

@ -0,0 +1,3 @@
engine-strict=true
auto-install-peers=true
shamefully-hoist=true

9
.prettierrc.json Normal file
View File

@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"tabWidth": 2,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "none",
"plugins": ["@shopify/prettier-plugin-liquid"]
}

37
Dockerfile Normal file
View File

@ -0,0 +1,37 @@
FROM node:20-slim
ARG SHOPIFY_API_KEY
ARG SHOPIFY_API_SECRET
ARG SCOPES
ARG HOST
Run echo "SHOPIFY_API_KEY=$SHOPIFY_API_KEY"
RUN echo "SHOPIFY_API_SECRET=$SHOPIFY_API_SECRET"
RUN echo "SCOPES=$SCOPES"
RUN echo "HOST=$HOST"
ENV SHOPIFY_API_KEY=$SHOPIFY_API_KEY
ENV SHOPIFY_API_SECRET=$SHOPIFY_API_SECRET
ENV SCOPES=$SCOPES
ENV HOST=$HOST
EXPOSE 8081
WORKDIR /app
# COPY .env.prod .env
# COPY start.sh ./start.sh
# COPY shopify.app.prod.toml shopify.app.toml
COPY web/server .
RUN npm install
# RUN npm install @shopify/cli -g
COPY web/client ./client
WORKDIR /app/client
RUN npm install && npm run build
WORKDIR /app
# RUN chmod +x start.sh
# CMD ["sh", "./start.sh"]
ENV NODE_ENV=dev
CMD ["npm", "run", "serve"]

21
LICENSE.md Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Mini-Sylar
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

256
README.md Normal file
View File

@ -0,0 +1,256 @@
# Shopify App Template Using Vue v.2 🟢
[![MadeWithVueJs.com shield](https://madewithvuejs.com/storage/repo-shields/4969-shield.svg)](https://madewithvuejs.com/p/shopify-vue-app-template/shield-link)
![Screenshot](https://drive.google.com/uc?id=1VKbiGd09QJ9c_TjpffQ5zasqxVLzqfgc)
A template for building Shopify apps using Vue.js as the frontend. It is based on the [Shopify App Node](https://github.com/Shopify/shopify-app-template-node) template.
---
## Table of Contents
1. [Getting Started](#getting-started)
2. [What is Included?](#what-is-included)
3. [Internationalization](#internationalization)
4. [New Features in v.2.0](#new-features-in-v20)
5. [FAQ](#faq)
6. [Screenshots](#screenshots)
7. [App Submission](#app-submission)
8. [License](#license)
---
## Getting Started
1. Clone this repository or `npx degit Mini-Sylar/shopify-app-vue-template your-app-name`
2. Run `npm install` in the root directory
3. Run `npm run dev:reset` to configure your app (Initial setup only!)
4. Run `npm run dev` to start the app (Subsequent runs)
5. See `package.json` for other scripts
---
## What is Included?
### Vue Starter 💚
- [Vue.js 3.5](https://vuejs.org/)
- [Vue Router 4](https://router.vuejs.org/) for single-page app routing
- [Vue i18n](https://vue-i18n.intlify.dev/) for app localization
- [Pinia](https://pinia.esm.dev/) for state management
---
## Internationalization 🌍
### Adding a New Translation
- Use `Vue i18n` for app localization. To add a new language, create a new JSON file in the [`Locales Folder`](./web/frontend/src/locales/) and add the translations. See [i18n.js](./web/frontend/src/i18n.js) for setup.
- All translation files are lazily loaded, meaning only the translations for the current language are loaded.
- The default language is what Shopify returns via the `locale` query parameter. If not set, it falls back to `en`.
- Vue Router embeds the language in the URL, e.g., `localhost:3000/en` or `localhost:3000/zh/about`.
- The template has been localized. See the [`Locales Folder`](./web/frontend/src/locales/) folder. Translations may not be 100% accurate, so pull requests are welcome.
---
## New Features in v.2.0
### Folder Structure
#### Updated Structure:
```
root/
├── client/ # Frontend Vue app, See client README.md
├── server/ # Backend Node.js app
│ ├── database/ # DB configuration (default: SQLite)
│ ├── middleware/ # Middleware for user capture
│ ├── models/ # Models for User and Webhook
│ ├── routes/ # Default product routes
│ ├── services/ # Shopify product creator
│ ├── utils/ # Utilities (locale, webhook processing)
│ ├── webhook/ # Webhook handlers (GDPR compliance included)
│ ├── index.js # Entry point
│ └── shopify.js # Shopify configuration
```
- **Prettier** and **ESLint** configurations are now project-wide.
- ESLint updated to use the new flat config.
---
### Shortcut Commands
| Command | Description |
|-------------------------|-------------------------------------------------------------------------|
| `npm run shopify` | Run Shopify CLI commands |
| `npm run build` | Build the project (frontend and backend) |
| `npm run dev` | Start the development server |
| `npm run dev:reset` | Reset Shopify configuration |
| `npm run dev:webhook` | Trigger a webhook. Use `<domain>/api/webhooks` when asked for a domain |
| `npm run info` | Display info about the Shopify app |
| `npm run generate` | Generate a theme extension |
| `npm run deploy` | Deploy the app |
| `npm run show:env` | Show environment variables for production deployment |
| `npm run lint` | Run ESLint on the entire project |
| `npm run lint:server` | Run ESLint on the server only |
| `npm run lint:client` | Run ESLint on the client only |
| `npm run format:server` | Run Prettier formatting on the server |
| `npm run format:client` | Run Prettier formatting on the client |
| `npm run client:install`| Install client dependencies |
| `npm run client:uninstall`| Uninstall client dependencies |
| `npm run server:install`| Install server dependencies |
| `npm run server:uninstall`| Uninstall server dependencies |
---
### Backend Updates
- **GraphQL:** Removed REST resources in favor of GraphQL, as REST will soon be deprecated.
- **New Models:**
- **User Model:** Created when a user installs the app.
- **Webhook Model:** Tracks fired webhooks to prevent duplication.
- **Webhook Processing:**
- Verification and processing utilities added (new in v.2).
- **Bug Fix:** Fixed an issue with the product creator service.
---
### Frontend Updates
- Renamed `helpers` folder to `services`.
- Updated `useAuthenticatedFetch`:
- Now accepts custom headers in a config object.
- Includes `enableI18nInHeaders` to pass the user's locale (true by default).
- Locale can be read using the `getLocalePreferencesFromRequest` function in `utils.js` (server).
---
### Deployment Enhancements
- Updated **Dockerfile** for simpler deployment.
- Tested on Render.com.
- Added example `shopify.app.example.toml` configuration file.
- Allows multiple configurations (e.g., `shopify.app.staging.toml`).
- Production configurations should not be committed to avoid exposing sensitive information.
---
## FAQ
<details>
<summary>How do I deploy this app?</summary>
#### Using My Own Server (Linux VPS/Render.com/Heroku)
1. Set up your domain, e.g., `https://shopify-vue.minisylar.com`.
2. Run `npm run show:env` to retrieve environment variables:
```
SHOPIFY_API_KEY=<YOUR_KEY>
SHOPIFY_API_SECRET=<YOUR_SECRET>
SCOPES="write_products,read_products"
HOST=https://shopify-vue.minisylar.com
```
#### Using Dockerfile
- Add the variables in the environment section of your hosting service (e.g., Render).
- Build and deploy the Dockerfile.
- For manual deployment:
```bash
docker build --build-arg SHOPIFY_API_KEY=<your_api_key> --build-arg SHOPIFY_API_SECRET=<your_api_secret> \
--build-arg SCOPES=<your_scopes> --build-arg HOST=<your_host> -t <image_name>:<tag> .
```
> **Note:** Omit `<` and `>` when providing values. Store secrets securely if using CI/CD pipelines.
</details>
<details>
<summary>How do I use MySQL or PostgreSQL for production?</summary>
#### MySQL Example
```diff
- import { SQLiteSessionStorage } from "@shopify/shopify-app-session-storage-sqlite";
+ import { MySQLSessionStorage } from "@shopify/shopify-app-session-storage-mysql";
sessionStorage:
process.env.NODE_ENV === "production"
? MySQLSessionStorage.withCredentials(
process.env.DATABASE_HOST,
process.env.DATABASE_SESSION,
process.env.DATABASE_USER,
process.env.DATABASE_PASSWORD,
{ connectionPoolLimit: 100 }
)
: new SQLiteSessionStorage(DB_PATH),
```
#### PostgreSQL Example
```diff
+ import { PostgreSQLSessionStorage } from "@shopify/shopify-app-session-storage-postgresql";
sessionStorage: PostgreSQLSessionStorage.withCredentials(
process.env.DATABASE_HOST,
process.env.DATABASE_SESSION,
process.env.DATABASE_USER,
process.env.DATABASE_PASSWORD
);
```
</details>
<details>
<summary>How to call external APIs?</summary>
Always call APIs from the server and forward responses to the frontend:
```javascript
app.get("/api/external-api", async (_req, res) => {
try {
const response = await fetch("https://dummyjson.com/products", { method: "GET" });
if (response.ok) {
res.status(200).send(await response.json());
} else {
res.status(500).send({ error: "Failed to fetch data" });
}
} catch (error) {
res.status(500).send({ error: error.message });
}
});
```
</details>
<details>
<summary>How to resolve CORS errors?</summary>
- Verify configuration in `shopify.<your_app>.toml`.
- Ensure the dev domain matches the preview URL.
- Run `npm run dev:reset` to reset the config, then `npm run deploy` to push changes.
</details>
<details>
<summary>How to update my scopes?</summary>
1. Update the `scopes` in your `.toml` file. See [Shopify Access Scopes](https://shopify.dev/docs/api/usage/access-scopes).
2. Run `npm run deploy`.
3. Uninstall and reinstall the app in the Shopify admin dashboard.
</details>
---
## Screenshots
![Screenshot](https://drive.google.com/uc?id=1p32XhaiVRQ9eSAmNQ1Hk2T-V5hmb9CFa)
![Screenshot](https://drive.google.com/uc?id=1yCr3lc3yqzgyV3ZiTSJjlIEVPtNY27LX)
---
## App Submission
Built an app using this template? Submit it here: [App submission form](https://forms.gle/K8VGCqvcvfBRSug58).

36
SECURITY.md Normal file
View File

@ -0,0 +1,36 @@
# Security Policy
## Supported versions
### New features
New features will only be added to the `main` branch and will not be made available in point releases.
### Bug fixes
Only the latest release series will receive bug fixes. When enough bugs are fixed and its deemed worthy to release a new gem, this is the branch it happens from.
### Security issues
Only the latest release series will receive patches and new versions in case of a security issue.
### Severe security issues
For severe security issues we will provide new versions as above, and also the last major release series will receive patches and new versions. The classification of the security issue is judged by the core team.
### Unsupported Release Series
When a release series is no longer supported, it's your own responsibility to deal with bugs and security issues. If you are not comfortable maintaining your own versions, you should upgrade to a supported version.
## Reporting a bug
Open an issue on the GitHub repository.
## Disclosure Policy
We look forward to working with all security researchers and strive to be respectful, always assume the best and treat others as peers. We expect the same in return from all participants. To achieve this, our team strives to:
- Reply to all reports within one business day and triage within two business days (if applicable)
- Be as transparent as possible, answering all inquires about our report decisions and adding hackers to duplicate HackerOne reports
- Award bounties within a week of resolution (excluding extenuating circumstances)
- Only close reports as N/A when the issue reported is included in Known Issues, Ineligible Vulnerabilities Types or lacks evidence of a vulnerability

35
eslint.config.mjs Normal file
View File

@ -0,0 +1,35 @@
import js from '@eslint/js'
import pluginVue from 'eslint-plugin-vue'
import globals from 'globals'
export default [
{
name: 'app/files-to-lint',
files: ['**/*.{js,mjs,jsx,vue,liquid}']
},
{
name: 'app/files-to-ignore',
ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**']
},
{
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: {
...globals.browser,
...globals.node,
myCustomGlobal: 'readonly'
}
}
// ...other config
},
js.configs.recommended,
{
rules: {
'no-unused-vars': 'warn'
}
},
...pluginVue.configs['flat/essential']
]

11
jsconfig.json Normal file
View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
},
"types": [
"@ownego/polaris-vue/dist/volar.d.ts"
]
},
"exclude": ["node_modules", "dist"]
}

50
package.json Normal file
View File

@ -0,0 +1,50 @@
{
"name": "skape-payment-rules",
"version": "1.0.0",
"main": "web/server/src/index.js",
"license": "MIT",
"type": "module",
"private": true,
"workspaces": [
"web/client",
"web/server"
],
"scripts": {
"shopify": "shopify",
"build": "shopify app build",
"dev": "shopify app dev --tunnel-url=https://payment-rules-dev.skape.store:3010",
"dev:reset": "shopify app dev --reset",
"dev:webhook": "shopify app webhook trigger",
"info": "shopify app info",
"generate": "shopify app generate",
"deploy": "shopify app deploy",
"show:env": "shopify app env show",
"lint": "eslint web/**/src/**/*.{js,vue,ts} --fix --ignore-pattern .gitignore",
"lint:server": "eslint web/server/src/**/*.{js,ts} --fix --ignore-pattern .gitignore",
"lint:client": "eslint web/client/src/**/*.{js,vue,ts} --fix --ignore-pattern .gitignore",
"lint:extensions": "eslint extensions/**/*.{js,ts,liquid} --fix --ignore-pattern .gitignore",
"format:server": "prettier --write web/server/src/",
"format:client": "prettier --write web/client/src/",
"format:extensions": "prettier --write extensions/**/*.{js,ts,liquid}",
"client:install": "npm install --workspace web/client",
"client:uninstall": "npm uninstall --workspace web/client",
"server:install": "npm install --workspace web/server",
"server:uninstall": "npm uninstall --workspace web/server"
},
"optionalDependencies": {
"@shopify/cli": "^3.58.2"
},
"devDependencies": {
"@eslint/js": "^9.18.0",
"@shopify/prettier-plugin-liquid": "^1.8.0",
"@vue/eslint-config-prettier": "^10.1.0",
"cli-color": "^2.0.4",
"dotenv": "^16.4.7",
"eslint": "^9.18.0",
"eslint-plugin-vue": "^9.32.0",
"prettier": "^3.5.1"
},
"dependencies": {
"@shopify/shopify-app-session-storage-postgresql": "^4.0.17"
}
}

28
web/client/.gitignore vendored Normal file
View File

@ -0,0 +1,28 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

58
web/client/README.md Normal file
View File

@ -0,0 +1,58 @@
# frontend
Frontend for shopify-app-vue-template. Dependencies are automatically installed when you run `npm run dev` from the root directory.
## Installed Dependencies
- Vue Router
- Pinia
- Vue i18n
## Project Structure
```
├─ client
├─ .gitignore
├─ index.html
├─ package-lock.json
├─ package.json
├─ public
│ └─ favicon.ico
├─ README.md
├─ shopify.web.toml
├─ src
│ ├─ App.vue
│ ├─ assets
│ │ ├─ base.css
│ │ ├─ images
│ │ │ └─ home-trophy-vue.png
│ │ └─ main.css
│ ├─ components
│ │ ├─ About
│ │ │ └─ TheAbout.vue
│ │ ├─ Home
│ │ │ └─ TheHome.vue
│ │ └─ NavBar
│ │ ├─ LanguageSwitcher.vue
│ │ └─ WelcomeNavBar.vue
│ ├─ services
│ │ └─ useAuthenticatedFetch.js
│ ├─ i18n.js
│ ├─ locales
│ │ ├─ en.json
│ │ └─ zh.json
│ ├─ main.js
│ ├─ plugins
│ │ └─ appBridge.js
│ ├─ router
│ │ └─ index.js
│ ├─ stores
│ │ └─ products.js
│ └─ views
│ ├─ AboutView.vue
│ ├─ ExitIframeView.vue
│ ├─ HomeView.vue
│ └─ NotFoundView.vue
└─ vite.config.js
```

16
web/client/index.html Normal file
View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://cdn.shopify.com/" />
<meta name="shopify-api-key" content="b5d33b2b59910d8bf25e8f57727fb03c" />
<script src="https://cdn.shopify.com/shopifycloud/app-bridge.js"></script>
<title>SKAPE - Shopify App Template</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

2087
web/client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
web/client/package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "client",
"version": "2.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint src/**/*.{js,vue,ts} --fix --ignore-pattern .gitignore",
"format": "prettier --write src/"
},
"dependencies": {
"@ownego/polaris-vue": "^2.1.20",
"@shopify/app-bridge": "^3.7.10",
"@shopify/app-bridge-types": "^0.0.16",
"apexcharts": "^4.4.0",
"pinia": "^2.3.0",
"recharts": "^2.15.1",
"vue": "^3.5.13",
"vue-i18n": "^11.0.1",
"vue-router": "^4.5.0",
"vue3-apexcharts": "^1.8.0"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.10.5",
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^6.0.7"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -0,0 +1,5 @@
type="frontend"
[commands]
dev = "npm run format && npm run dev"
build = "npm run build"

102
web/client/src/App.vue Normal file
View File

@ -0,0 +1,102 @@
<template>
<AppProvider :i18n="i18nConfig" :key="forceRerender">
<div class="app-container">
<ui-nav-menu>
<a href="/" rel="home"></a>
<a href="/home">{{ $t('NavBar.home') }}</a>
<a href="/support">{{ $t('NavBar.support') }}</a>
</ui-nav-menu>
<main class="content">
<RouterView v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component"></component>
</transition>
</RouterView>
</main>
<FooterHelp class="footer">
<LanguageSwitcher @language-change="handleLanguageChangeEvent" />
<Text as="p" variant="bodyXs" style="margin-top: 8px"
>Last updated {{ daysSinceUpdate }} {{ dayText }} ago</Text
>
</FooterHelp>
</div>
</AppProvider>
</template>
<script setup>
import { ref, computed } from 'vue'
import { RouterView } from 'vue-router'
import LanguageSwitcher from '@/components/Navigation/LanguageSwitcher.vue'
import {
i18n,
getI18nLanguage,
messageFr,
messageEn,
messageEs,
messageDe,
messageIt
} from '@/i18n'
const getMessageListByLanguage = (lang) => {
const messages = {
en: messageEn,
fr: messageFr,
es: messageEs,
de: messageDe,
it: messageIt
}
const primary = messages[lang] || messageEn
return [primary, messageEn]
}
const handleLanguageChangeEvent = (lang) => {
// TODO: make it dynamic
i18nConfig.value = getMessageListByLanguage(lang)
forceRerender.value++
}
const forceRerender = ref(0)
const i18nConfig = ref(getMessageListByLanguage(getI18nLanguage(i18n)))
// Version update
const lastUpdateDate = new Date('2025-07-25')
const today = new Date()
const daysSinceUpdate = computed(() => {
const diffTime = today - lastUpdateDate
return Math.floor(diffTime / (1000 * 60 * 60 * 24))
})
const dayText = computed(() => (daysSinceUpdate.value === 1 ? 'day' : 'days'))
</script>
<style scoped>
@media (max-width: 600px) {
.app-container {
padding: 10px;
}
}
/* Flexbox solution for sticky footer */
.app-container {
display: flex;
flex-direction: column;
padding: 10px;
min-height: 100vh; /* Full viewport height */
}
.content {
flex: 1; /* Takes up all available space */
padding-bottom: 20px; /* Space above footer */
}
.footer {
max-width: 100%;
overflow-x: hidden;
box-sizing: border-box;
}
</style>

View File

@ -0,0 +1,351 @@
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
/* Document
========================================================================== */
/**
* 1. Correct the line height in all browsers.
* 2. Prevent adjustments of font size after orientation changes in iOS.
*/
html {
line-height: 1.15; /* 1 */
-webkit-text-size-adjust: 100%; /* 2 */
}
/* Sections
========================================================================== */
/**
* Remove the margin in all browsers.
*/
body {
margin: 0;
}
/**
* Render the `main` element consistently in IE.
*/
main {
display: block;
}
/**
* Correct the font size and margin on `h1` elements within `section` and
* `article` contexts in Chrome, Firefox, and Safari.
*/
h1 {
font-size: 2em;
margin: 0.67em 0;
}
/* Grouping content
========================================================================== */
/**
* 1. Add the correct box sizing in Firefox.
* 2. Show the overflow in Edge and IE.
*/
hr {
box-sizing: content-box; /* 1 */
height: 0; /* 1 */
overflow: visible; /* 2 */
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
pre {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/* Text-level semantics
========================================================================== */
/**
* Remove the gray background on active links in IE 10.
*/
a {
background-color: transparent;
}
/**
* 1. Remove the bottom border in Chrome 57-
* 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
*/
abbr[title] {
border-bottom: none; /* 1 */
text-decoration: underline; /* 2 */
text-decoration: underline dotted; /* 2 */
}
/**
* Add the correct font weight in Chrome, Edge, and Safari.
*/
b,
strong {
font-weight: bolder;
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/**
* Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/**
* Prevent `sub` and `sup` elements from affecting the line height in
* all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/* Embedded content
========================================================================== */
/**
* Remove the border on images inside links in IE 10.
*/
img {
border-style: none;
}
/* Forms
========================================================================== */
/**
* 1. Change the font styles in all browsers.
* 2. Remove the margin in Firefox and Safari.
*/
button,
input,
optgroup,
select,
textarea {
font-family: inherit; /* 1 */
font-size: 100%; /* 1 */
line-height: 1.15; /* 1 */
margin: 0; /* 2 */
}
/**
* Show the overflow in IE.
* 1. Show the overflow in Edge.
*/
button,
input {
/* 1 */
overflow: visible;
}
/**
* Remove the inheritance of text transform in Edge, Firefox, and IE.
* 1. Remove the inheritance of text transform in Firefox.
*/
button,
select {
/* 1 */
text-transform: none;
}
/**
* Correct the inability to style clickable types in iOS and Safari.
*/
button,
[type='button'],
[type='reset'],
[type='submit'] {
-webkit-appearance: button;
}
/**
* Remove the inner border and padding in Firefox.
*/
button::-moz-focus-inner,
[type='button']::-moz-focus-inner,
[type='reset']::-moz-focus-inner,
[type='submit']::-moz-focus-inner {
border-style: none;
padding: 0;
}
/**
* Restore the focus styles unset by the previous rule.
*/
button:-moz-focusring,
[type='button']:-moz-focusring,
[type='reset']:-moz-focusring,
[type='submit']:-moz-focusring {
outline: 1px dotted ButtonText;
}
/**
* Correct the padding in Firefox.
*/
fieldset {
padding: 0.35em 0.75em 0.625em;
}
/**
* 1. Correct the text wrapping in Edge and IE.
* 2. Correct the color inheritance from `fieldset` elements in IE.
* 3. Remove the padding so developers are not caught out when they zero out
* `fieldset` elements in all browsers.
*/
legend {
box-sizing: border-box; /* 1 */
color: inherit; /* 2 */
display: table; /* 1 */
max-width: 100%; /* 1 */
padding: 0; /* 3 */
white-space: normal; /* 1 */
}
/**
* Add the correct vertical alignment in Chrome, Firefox, and Opera.
*/
progress {
vertical-align: baseline;
}
/**
* Remove the default vertical scrollbar in IE 10+.
*/
textarea {
overflow: auto;
}
/**
* 1. Add the correct box sizing in IE 10.
* 2. Remove the padding in IE 10.
*/
[type='checkbox'],
[type='radio'] {
box-sizing: border-box; /* 1 */
padding: 0; /* 2 */
}
/**
* Correct the cursor style of increment and decrement buttons in Chrome.
*/
[type='number']::-webkit-inner-spin-button,
[type='number']::-webkit-outer-spin-button {
height: auto;
}
/**
* 1. Correct the odd appearance in Chrome and Safari.
* 2. Correct the outline style in Safari.
*/
[type='search'] {
-webkit-appearance: textfield; /* 1 */
outline-offset: -2px; /* 2 */
}
/**
* Remove the inner padding in Chrome and Safari on macOS.
*/
[type='search']::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
* 1. Correct the inability to style clickable types in iOS and Safari.
* 2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button; /* 1 */
font: inherit; /* 2 */
}
/* Interactive
========================================================================== */
/*
* Add the correct display in Edge, IE 10+, and Firefox.
*/
details {
display: block;
}
/*
* Add the correct display in all browsers.
*/
summary {
display: list-item;
}
/* Misc
========================================================================== */
/**
* Add the correct display in IE 10+.
*/
template {
display: none;
}
/**
* Add the correct display in IE 10.
*/
[hidden] {
display: none;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1,36 @@
@import './base.css';
/* Fade Animation */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s;
-webkit-transition: opacity 0.2s;
-moz-transition: opacity 0.2s;
-ms-transition: opacity 0.2s;
-o-transition: opacity 0.2s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
body {
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Open Sans',
'Helvetica Neue',
sans-serif;
}
.main-container {
width: min(90%, 1200px);
margin: 0 auto;
}

View File

@ -0,0 +1,365 @@
<template>
<Popover
:active="popoverActive"
autofocus-target="none"
preferred-alignment="left"
preferred-position="below"
:fluid-content="true"
:sectioned="false"
:full-height="true"
@close="setPopoverActive(false)"
>
<template #activator>
<Button size="slim" :icon="CalendarIcon" @click="togglePopover">
{{ buttonValue }}
</Button>
</template>
<Pane fixed>
<InlineGrid
:columns="{
xs: '1fr',
mdDown: '1fr',
md: 'max-content max-content'
}"
:gap="0"
ref="datePickerRef"
>
<Box
:max-width="mdDown ? '516px' : '212px'"
:width="mdDown ? '100%' : '212px'"
:padding="{ xs: 500, md: 0 }"
:padding-block-end="{ xs: 100, md: 0 }"
>
<Scrollable v-if="!mdDown" style="height: 334px">
<OptionList
:options="
ranges.map((range) => ({
value: range.alias,
label: range.title
}))
"
:selected="[activeDateRange.alias]"
@change="handleRangeChange"
/>
</Scrollable>
<Select
v-else
label="dateRangeLabel"
label-hidden
:options="ranges.map(({ title, alias }) => title || alias)"
:value="activeDateRange?.title || activeDateRange?.alias || ''"
@change="handleRangeChange"
/>
</Box>
<Box :padding="{ xs: 500 }" :max-width="mdDown ? '320px' : '516px'">
<BlockStack gap="400">
<InlineStack gap="100" :wrap="false">
<div style="flex-grow: 1">
<TextField
role="combobox"
label="Since"
label-hidden
tone="magic"
:modelValue="inputValues.since"
@input="handleStartInputValueChange"
@blur="handleInputBlur"
auto-complete="off"
>
<template #prefix>
<Icon :source="CalendarIcon" />
</template>
</TextField>
</div>
<Icon :source="ArrowRightIcon" />
<div style="flex-grow: 1">
<TextField
role="combobox"
label="Until"
label-hidden
tone="magic"
:modelValue="inputValues.until"
@input="handleEndInputValueChange"
@blur="handleInputBlur"
auto-complete="off"
>
<template #prefix>
<Icon :source="CalendarIcon" />
</template>
</TextField>
</div>
</InlineStack>
<div>
<DatePicker
weekStartsOn="1"
allow-range
:multi-month="shouldShowMultiMonth"
:disableDatesAfter="today"
:month="dateState.month"
:year="dateState.year"
v-model="selectedDates"
@month-change="handleMonthChange"
@change="handleCalendarChange"
/>
</div>
</BlockStack>
</Box>
</InlineGrid>
</Pane>
<Pane fixed>
<PopoverSection>
<InlineStack gap="100" align="end">
<Button @click="cancel">Cancel</Button>
<Button variant="primary" @click="apply">Apply</Button>
</InlineStack>
</PopoverSection>
</Pane>
</Popover>
</template>
<script setup>
import { useBreakpoints } from '@/composables/useBreakpoints'
import { ref, computed, watch } from 'vue'
import {
Popover,
Button,
InlineGrid,
Box,
Scrollable,
OptionList,
Select,
TextField,
Icon,
BlockStack,
InlineStack,
DatePicker
} from '@ownego/polaris-vue'
import CalendarIcon from '@shopify/polaris-icons/dist/svg/CalendarIcon.svg'
import ArrowRightIcon from '@shopify/polaris-icons/dist/svg/ArrowRightIcon.svg'
// Events
const emit = defineEmits(['on-apply'])
// Helpers
function formatDateToYearMonthDayDateString(date) {
const year = String(date.getFullYear())
let month = String(date.getMonth() + 1)
let day = String(date.getDate())
if (month.length < 2) {
month = String(month).padStart(2, '0')
}
if (day.length < 2) {
day = String(day).padStart(2, '0')
}
return [year, month, day].join('-')
}
function formatDate(date) {
return formatDateToYearMonthDayDateString(date)
}
function parseYearMonthDayDateString(input) {
// Date-only strings (e.g. "1970-01-01") are treated as UTC, not local time
// when using new Date()
// We need to split year, month, day to pass into new Date() separately
// to get a localized Date
const [year, month, day] = input.split('-')
return new Date(Number(year), Number(month) - 1, Number(day))
}
// Breakpoints
const { mdDown, lgUp } = useBreakpoints()
const shouldShowMultiMonth = lgUp
// Dates
const today = new Date(new Date().setHours(0, 0, 0, 0))
const yesterday = new Date(new Date(today).setDate(today.getDate() - 1))
// Date ranges
const ranges = [
{
title: 'Today',
alias: 'today',
period: { since: today, until: today }
},
{
title: 'Yesterday',
alias: 'yesterday',
period: { since: yesterday, until: yesterday }
},
{
title: 'Last 7 days',
alias: 'last7days',
period: {
since: new Date(new Date(today).setDate(today.getDate() - 7)),
until: yesterday
}
},
{
title: 'Last 30 days',
alias: 'last30days',
period: {
since: new Date(new Date(today).setDate(today.getDate() - 30)),
until: yesterday
}
}
]
// State
const popoverActive = ref(false)
const activeDateRange = ref(ranges[0])
const datePickerRef = ref(null)
const selectedDates = ref({
start: activeDateRange.value.period.since,
end: activeDateRange.value.period.until
})
const dateState = ref({
month: activeDateRange.value.period.since.getMonth(),
year: activeDateRange.value.period.since.getFullYear()
})
const inputValues = ref({
since: formatDate(ranges[0].period.since),
until: formatDate(ranges[0].period.until)
})
const buttonValue = computed(() => {
const { title, period } = activeDateRange.value
return title === 'Custom'
? `${period.since.toDateString()} - ${period.until.toDateString()}`
: title
})
// Handlers
const togglePopover = () => {
popoverActive.value = !popoverActive.value
}
const setPopoverActive = (value) => {
popoverActive.value = value
}
const handleRangeChange = (value) => {
const selectedRange = ranges.find((range) => range.alias === value[0] || range.title === value)
if (selectedRange) {
activeDateRange.value = selectedRange
inputValues.value.since = formatDate(selectedRange.period.since)
inputValues.value.until = formatDate(selectedRange.period.until)
selectedDates.value.start = selectedRange.period.since
selectedDates.value.end = selectedRange.period.until
dateState.value.month = selectedRange.period.since.getMonth()
dateState.value.year = selectedRange.period.since.getFullYear()
}
}
const handleStartInputValueChange = (event, value) => {
inputValues.value.since = value
if (isValidDate(value)) {
const newSince = parseYearMonthDayDateString(value)
activeDateRange.value = {
...activeDateRange.value,
period: {
since: newSince,
until: activeDateRange.value.period.until
}
}
selectedDates.value.start = activeDateRange.value.period.since
selectedDates.value.end = activeDateRange.value.period.until
dateState.value.month = activeDateRange.value.period.since.getMonth()
dateState.value.year = activeDateRange.value.period.since.getFullYear()
}
}
const handleEndInputValueChange = (event, value) => {
inputValues.value.until = value
if (isValidDate(value)) {
const newUntil = parseYearMonthDayDateString(value)
activeDateRange.value = {
...activeDateRange.value,
period: {
since: activeDateRange.value.period.since,
until: newUntil
}
}
selectedDates.value.start = activeDateRange.value.period.since
selectedDates.value.end = activeDateRange.value.period.until
dateState.value.month = activeDateRange.value.period.since.getMonth()
dateState.value.year = activeDateRange.value.period.since.getFullYear()
}
}
const handleInputBlur = ({ relatedTarget }) => {
console.log('==> handleInputBlur')
if (relatedTarget && !isNodeWithinPopover(relatedTarget)) {
popoverActive.value = false
}
}
const handleMonthChange = (month, year) => {
dateState.value = { month, year }
}
const handleCalendarChange = ({ start, end }) => {
const newRange = ranges.find(
(range) =>
range.period.since.valueOf() === start.valueOf() &&
range.period.until.valueOf() === end.valueOf()
) || {
alias: 'custom',
title: 'Custom',
period: { since: start, until: end }
}
activeDateRange.value = newRange
}
const apply = () => {
emit('on-apply', {
since: new Date(activeDateRange.value.period.since.setHours(0, 0, 0, 0)),
until: new Date(activeDateRange.value.period.until.setHours(23, 59, 59, 0))
})
popoverActive.value = false
}
const cancel = () => {
popoverActive.value = false
}
// Utilities
function isDate(date) {
return !isNaN(new Date(date).getDate())
}
function isValidYearMonthDayDateString(date) {
return /^\d{4}-\d{1,2}-\d{1,2}/.test(date) && isDate(date)
}
const isValidDate = (date) => {
return date.length === 10 && isValidYearMonthDayDateString(date)
}
// const parseYearMonthDayDateString = (input) => {
// const [year, month, day] = input.split('-')
// return new Date(Number(year), Number(month) - 1, Number(day))
// }
const isNodeWithinPopover = (node) => {
return true
// return datePickerRef.value?.contains(node)
}
// Watch for activeDateRange changes
watch(activeDateRange, (newRange) => {
inputValues.value = {
since: formatDate(newRange.period.since),
until: formatDate(newRange.period.until)
}
})
</script>

View File

@ -0,0 +1,24 @@
<template>
<div :style="{ position: 'absolute', top, left }">
<div class="inline-flex justify-center">
<Spinner :accessibilityLabel="accessibilityLabel" :size="size" />
</div>
</div>
</template>
<script setup>
import { Spinner } from '@ownego/polaris-vue'
defineProps({
accessibilityLabel: String,
size: String,
top: {
type: String,
default: '50%'
},
left: {
type: String,
default: '50%'
}
})
</script>

View File

@ -0,0 +1,155 @@
<template>
<Page>
<BlockStack gap="400">
<Layout>
<LayoutSection>
<BlockStack inline-align="start">
<InlineStack :wrap="false" :blockAlign="start" :align="start" direction="row" gap="300">
<Text as="h2" variant="heading3xl" style="margin-top: 30px; margin-bottom: 30px">
{{ $t('welcome.title') }}
<Text
as="span"
v-if="shopName !== ''"
variant="heading3xl"
style="margin-top: 30px; margin-bottom: 30px"
>
{{ ' ' + shopName + ',' }}
</Text>
</Text>
<SkeletonDisplayText
v-if="shopName === ''"
size="extraLarge"
style="width: 300px; margin-top: 30px; margin-bottom: 30px"
/>
</InlineStack>
<Text as="p" variant="headingLg" fontWeight="regular" tone="subdued">
{{ $t('welcome.description') }}
</Text>
</BlockStack>
</LayoutSection>
</Layout>
<Layout>
<LayoutSection>
<Banner
title="Free plan activated"
:action="{
content: 'View all plans',
onAction: viewAllPlans,
variant: 'primary',
icon: ShieldCheckMarkIcon,
loading: checkLoading
}"
>
<BlockStack gap="200">
<Text as="p" variant="bodyMd">
You are currently on the Free Plan of %Shopify App Name%, which allows you to handle
5 orders automaticly
</Text>
</BlockStack>
</Banner>
</LayoutSection>
<LayoutSection>
<MediaCard
:title="$t('welcome.description')"
:primary-action="{
content: $t('videoCard.primary'),
icon: ThemeEditIcon,
url: deepLink,
disabled: deepLink === '/' ? true : false,
external: true
}"
:secondary-action="{
content: $t('videoCard.secondary'),
icon: QuestionCircleIcon,
onAction: openDocumentation
}"
:description="$t('videoCard.description')"
>
<VideoThumbnail
:video-length="108"
thumbnailUrl="https://burst.shopifycdn.com/photos/business-woman-smiling-in-office.jpg?width=1850"
@click="openDemoModal"
/>
<ui-modal id="demo-modal" variant="large">
<video class="demo-video" controls autoplay muted="false">
<source src="https://documentation.skape.store/demo.mp4" type="video/mp4" />
Your browser does not support the video tag.
</video>
<ui-title-bar title="Walkthrough video">
<button onclick="document.getElementById('demo-modal').hide()">Close</button>
</ui-title-bar>
</ui-modal>
</MediaCard>
</LayoutSection>
</Layout>
</BlockStack>
</Page>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import {
Page,
Layout,
LayoutSection,
Text,
MediaCard,
VideoThumbnail,
BlockStack,
Banner
} from '@ownego/polaris-vue'
import QuestionCircleIcon from '@shopify/polaris-icons/dist/svg/QuestionCircleIcon.svg'
import ShieldCheckMarkIcon from '@shopify/polaris-icons/dist/svg/ShieldCheckMarkIcon.svg'
// Reactive state
const shopName = ref('')
const deepLink = ref('/')
const checkLoading = ref(false)
const loadingShopName = ref(false)
onMounted(async () => {
try {
loadingShopName.value = true
const response = await fetch('/api/shop/name')
const newData = await response.json()
console.log('===/api/shop/name===> ', newData)
shopName.value = newData.shopName
loadingShopName.value = false
} catch (error) {
console.error('Error fetching data:', error)
}
})
const openDocumentation = () => {
window.open('https://documentation.skape.store', '_blank').focus()
}
const openDemoModal = () => {
document.getElementById('demo-modal').show()
}
const viewAllPlans = async () => {
const response = await fetch('/api/billing/manage')
const newData = await response.json()
console.log('===viewAllPlans===> ', newData)
}
</script>
<style scoped>
#demo-modal > div {
padding: 0;
margin: 0;
height: 500px;
}
.demo-video {
width: 100%;
height: 100%;
object-fit: contain;
}
</style>

View File

@ -0,0 +1,67 @@
<template>
<div class="language-switcher">
<!-- <label for="language-select">{{ $t('NavBar.selectLanguage') }}</label> -->
<select id="language-select" v-model="selectedLanguage" @change="changeLanguage">
<option v-for="lang in supportedLanguages" :key="lang.code" :value="lang.code">
{{ lang.name }}
</option>
</select>
</div>
</template>
<script setup>
import { ref } from 'vue'
import {
i18n,
getI18nLanguage,
setI18nLanguage,
loadLocaleMessages,
normalizeLocale,
APP_LOCALES
} from '@/i18n'
import { useI18n } from 'vue-i18n'
const { locale } = useI18n()
const emit = defineEmits(['language-change'])
console.log('Component locale:', locale.value)
console.log('Global locale:', i18n.global.locale.value)
// Supported languages
const supportedLanguages = ref(APP_LOCALES)
// Current selected language
const selectedLanguage = ref(normalizeLocale(getI18nLanguage(i18n)))
// Method to change language
const changeLanguage = async () => {
// Load locale messages if needed
if (!i18n.global.availableLocales.includes(selectedLanguage.value)) {
await loadLocaleMessages(i18n, selectedLanguage.value)
}
// Set i18n language if it's not already set
if (i18n.global.locale.value !== selectedLanguage.value) {
setI18nLanguage(i18n, normalizeLocale(selectedLanguage.value))
}
// Emit event to parent component
emit('language-change', selectedLanguage.value)
}
</script>
<style scoped>
.language-switcher {
display: flex;
align-items: end;
gap: 8px;
}
#language-select {
display: block;
margin: 0 auto;
padding: 4px 8px;
border: 1px solid #ccc;
border-radius: 4px;
}
</style>

View File

@ -0,0 +1,72 @@
<template>
<Page :title="$t('support.title')">
<Layout>
<LayoutSection>
<Banner :title="$t('support.bannerTitle')" tone="info">
<BlockStack gap="300">
<Text as="p" variant="bodyLg" fontWeight="semibold">
{{ $t('support.bannerSubtitle') }}
</Text>
<Text as="p" variant="bodyMd">
{{ $t('support.bannerDescription') }}
</Text>
<ButtonGroup>
<Button :icon="EmailNewsletterIcon" url="mailto:contact@skape.store" target="_blank">
{{ $t('support.contactUs') }}
</Button>
</ButtonGroup>
</BlockStack>
</Banner>
</LayoutSection>
<LayoutSection>
<Layout>
<LayoutSection variant="oneHalf">
<Card>
<BlockStack gap="300">
<Text as="h2" variant="headingMd">{{ $t('support.notFoundTitle') }}</Text>
<Text as="p" variant="bodyMd">{{ $t('support.notFoundDescription') }}</Text>
<ButtonGroup>
<Button :icon="ComposeIcon" url="mailto:support@skape.store" target="_blank">
{{ $t('support.featureRequest') }}
</Button>
</ButtonGroup>
</BlockStack>
</Card>
</LayoutSection>
<LayoutSection variant="oneHalf">
<Card>
<BlockStack gap="300">
<Text as="h2" variant="headingMd">{{ $t('support.needHelpTitle') }}</Text>
<ButtonGroup>
<Button
:icon="NoteIcon"
url="https://documentation.skape.store/"
target="_top"
external
>
{{ $t('support.documentation') }}
</Button>
<Button
:icon="EmailNewsletterIcon"
url="mailto:contact@skape.store"
target="_blank"
>
{{ $t('support.contactUs') }}
</Button>
</ButtonGroup>
</BlockStack>
</Card>
</LayoutSection>
</Layout>
</LayoutSection>
</Layout>
</Page>
</template>
<script setup>
import EmailNewsletterIcon from '@shopify/polaris-icons/dist/svg/EmailNewsletterIcon.svg'
import NoteIcon from '@shopify/polaris-icons/dist/svg/NoteIcon.svg'
import ComposeIcon from '@shopify/polaris-icons/dist/svg/ComposeIcon.svg'
</script>
<style scoped></style>

View File

@ -0,0 +1,63 @@
import { ref, onMounted, onUnmounted, computed } from 'vue'
// Define your breakpoints
const breakpoints = {
xs: 0,
sm: 576,
md: 768,
lg: 992,
xl: 1200
}
// Helper function to get the current breakpoint
function getBreakpoint(width) {
if (width < breakpoints.sm) return 'xs'
if (width < breakpoints.md) return 'sm'
if (width < breakpoints.lg) return 'md'
if (width < breakpoints.xl) return 'lg'
return 'xl'
}
export function useBreakpoints() {
const width = ref(window.innerWidth)
const breakpoint = ref(getBreakpoint(width.value))
// Update breakpoint on window resize
const updateBreakpoint = () => {
width.value = window.innerWidth
breakpoint.value = getBreakpoint(width.value)
}
// Add event listener for window resize
onMounted(() => {
window.addEventListener('resize', updateBreakpoint)
})
// Remove event listener on unmount
onUnmounted(() => {
window.removeEventListener('resize', updateBreakpoint)
})
// Computed properties for common breakpoints
const isXs = computed(() => breakpoint.value === 'xs')
const isSm = computed(() => breakpoint.value === 'sm')
const isMd = computed(() => breakpoint.value === 'md')
const isLg = computed(() => breakpoint.value === 'lg')
const isXl = computed(() => breakpoint.value === 'xl')
// Shorthands for common comparisons
const mdDown = computed(() => ['xs', 'sm', 'md'].includes(breakpoint.value))
const lgUp = computed(() => ['lg', 'xl'].includes(breakpoint.value))
return {
width,
breakpoint,
isXs,
isSm,
isMd,
isLg,
isXl,
mdDown,
lgUp
}
}

95
web/client/src/i18n.js Normal file
View File

@ -0,0 +1,95 @@
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import en from '@/locales/en.json'
import fr from '@/locales/fr.json'
import es from '@/locales/es.json'
import de from '@/locales/de.json'
import it from '@/locales/it.json'
export const APP_LOCALES = [
{ code: 'en', name: 'English' },
{ code: 'fr', name: 'Français' },
{ code: 'es', name: 'Español' },
{ code: 'de', name: 'Deutsch' },
{ code: 'it', name: 'Italiano' }
]
export function setupI18n(options = { locale: 'en' }) {
const i18n = createI18n(options)
setI18nLanguage(i18n, options.locale)
return i18n
}
export function getI18nLanguage(i18n) {
return i18n.mode === 'legacy' ? i18n.global.locale : i18n.global.locale.value
}
export function setI18nLanguage(i18n, locale) {
if (i18n.mode === 'legacy') {
i18n.global.locale = locale
} else {
i18n.global.locale.value = locale
}
/**
* NOTE:
* If you need to specify the language setting for headers, such as the `useAuthenticatedfetch`, set it here.
*/
document.querySelector('html').setAttribute('lang', locale)
localStorage.setItem('skape-carousel-locale', locale)
}
export async function loadLocaleMessages(i18n, locale) {
// load locale messages with dynamic import
const messages = await import(`./locales/${locale}.json`)
// set locale and locale message
i18n.global.setLocaleMessage(locale, messages.default)
return await nextTick()
}
/**
* Normalize a locale string to its base language code (e.g., 'en-US' -> 'en').
* Handles invalid or unexpected input gracefully.
*
* @param {string} locale - The locale string to normalize.
* @returns {string} - The normalized base language code (e.g., 'en'), or a default ('en') if input is invalid.
*/
export function normalizeLocale(locale) {
if (typeof locale !== 'string' || locale.trim() === '') {
console.warn('Invalid locale provided, defaulting to "en".')
return 'en' // Default fallback
}
const parts = locale.split('-')
const baseLocale = parts[0]?.trim().toLowerCase()
if (!baseLocale || baseLocale.length < 2 || baseLocale.length > 3) {
console.warn(`Unexpected locale format: "${locale}", defaulting to "en".`)
return 'en' // Default fallback
}
return baseLocale
}
export const i18n = setupI18n({
// locale: new URLSearchParams(window.location.search).get('locale') || 'en',
locale: localStorage.getItem('skape-carousel-locale') || 'en',
messages: {
en,
fr,
es,
de,
it
},
fallbackLocale: 'en',
legacy: false
})
// Export raw translations for AppProvider
export const messageFr = fr
export const messageEn = en
export const messageEs = es
export const messageDe = de
export const messageIt = it

View File

@ -0,0 +1,120 @@
{
"NavBar": {
"home": "Startseite",
"analytics": "Analysen",
"support": "Support",
"selectLanguage": "Sprache ändern"
},
"welcome": {
"title": "Willkommen {shopName}",
"description": "Die ultimative Lösung für Shopify-Händler, die ihre Produktpräsentationen mit dynamischen Karussells verbessern möchten"
},
"banner": {
"compatible": "Ihr Theme '{themeName}' ist kompatibel",
"incompatible": "Ihr Theme '{themeName}' ist nicht kompatibel.",
"action": "Theme-Kompatibilität prüfen",
"note": "SKAPE wird derzeit nur von Online Store 2.0 unterstützt.",
"advice": "Überprüfen Sie die Kompatibilität bei jedem Theme-Wechsel oder bei Änderungen erneut, um eine dauerhafte Kompatibilität sicherzustellen."
},
"videoCard": {
"title": "Ein Einführungsvideo zu unserer Karussell-App",
"primary": "Erstellen starten",
"secondary": "Mehr erfahren",
"description": "In diesem Video erfahren Sie, wie Sie mit SKAPE in weniger als einer Minute ein schönes und dynamisches Produktkarussell erstellen können."
},
"analytics": {
"title": "Analysen",
"products": {
"carouselClicks": "Anzahl der Klicks auf Karussell (Bild/Titel/Preis)",
"addToCartClicks": "Anzahl der Klicks auf \"Zum Warenkorb hinzufügen\"",
"topClicked": "Top 10 Am Häufigsten Geklickte Produkte",
"topAdded": "Top 10 Meist in den Warenkorb Gelegte Produkte"
},
"collections": {
"carouselClicks": "Anzahl der Klicks auf Karussell (Bild/Titel)",
"topClicked": "Top 10 Am Häufigsten Geklickte Kollektionen"
},
"media": {
"carouselClicks": "Anzahl der Klicks auf Karussell (Bilder/Videos)",
"topClicked": "Top 10 Am Häufigsten Geklickte Bilder & Videos"
},
"table": {
"productName": "Produktname",
"price": "Preis",
"clicks": "Anzahl der Klicks",
"rank": "Rang",
"collectionName": "Kollektionsname",
"numProducts": "Anzahl der Produkte",
"mediaName": "Medienname",
"mediaType": "Medientyp",
"mediaPosition": "Position im Slider"
},
"empty": {
"noProducts": {
"title": "Keine Produkte",
"description": "Es gibt keine Produkte in der Kollektion, die Sie ausgewählt haben"
},
"noCollections": {
"title": "Keine Kollektionen",
"description": "Es gibt keine Produkte in der Kollektion, die Sie ausgewählt haben"
},
"noMedia": {
"title": "Keine Medien",
"description": "Es gibt keine Produkte in der Kollektion, die Sie ausgewählt haben"
}
}
},
"support": {
"title": "Support",
"bannerTitle": "Wir bieten eine 45-tägige Geld-zurück-Garantie und erstatten den vollen Betrag, wenn Sie mit unserem Produkt nicht vollständig zufrieden sind.",
"bannerSubtitle": "Wir unterstützen Unternehmer!",
"bannerDescription": "Starte dein Online-Geschäft mit Vertrauen und nutze eine 30-tägige kostenlose Testversion, wenn dein Shop noch nicht live ist!",
"contactUs": "Kontaktiere uns",
"notFoundTitle": "Nicht gefunden, was du suchst?",
"notFoundDescription": "Teile uns deine spezifischen Anforderungen an unsere Karussell-App mit, und wir werden unser Bestes tun, um dir zu helfen.",
"featureRequest": "Feature anfragen",
"needHelpTitle": "Brauchst du Hilfe, um unser Produkt besser zu verstehen?",
"documentation": "Besuche unsere Dokumentation"
},
"Polaris": {
"DatePicker": {
"previousMonth": "Vorherigen Monat anzeigen, {previousMonthName} {showPreviousYear}",
"nextMonth": "Nächsten Monat anzeigen, {nextMonth} {nextYear}",
"today": "Heute",
"months": {
"january": "Januar",
"february": "Februar",
"march": "März",
"april": "April",
"may": "Mai",
"june": "Juni",
"july": "Juli",
"august": "August",
"september": "September",
"october": "Oktober",
"november": "November",
"december": "Dezember"
},
"daysAbbreviated": {
"monday": "Mo",
"tuesday": "Di",
"wednesday": "Mi",
"thursday": "Do",
"friday": "Fr",
"saturday": "Sa",
"sunday": "So"
},
"days": {
"monday": "Montag",
"tuesday": "Dienstag",
"wednesday": "Mittwoch",
"thursday": "Donnerstag",
"friday": "Freitag",
"saturday": "Samstag",
"sunday": "Sonntag"
},
"start": "Beginn der Periode",
"end": "Ende der Periode"
}
}
}

View File

@ -0,0 +1,120 @@
{
"NavBar": {
"home": "Welcome",
"analytics": "Analytics",
"support": "Support",
"selectLanguage": "Select Language"
},
"welcome": {
"title": "Hello {shopName}",
"description": "Customize this subtitle to catch your customers attention."
},
"banner": {
"compatible": "Your theme '{themeName}' is compatible",
"incompatible": "Your theme '{themeName}' is not compatible.",
"action": "Check theme compatibility",
"note": "SKAPE is currently supported only on Online Store 2.0.",
"advice": "Feel free to verify compatibility multiple times, particularly when switching to a new theme or making modifications to your current one, to ensure ongoing compatibility."
},
"videoCard": {
"title": "A walkthrough video of our carousel app",
"primary": "Start building",
"secondary": "Learn more",
"description": "Nunc in volutpat felis. Aenean sed ornare nisi. Fusce at lacus eu nunc maximus aliquet. Nulla facilisi. Mauris eu facilisis ante, et pretium tellus. Aenean justo magna, mattis vestibulum ante non, sodales tincidunt velit."
},
"analytics": {
"title": "Analytics",
"products": {
"carouselClicks": "Number of clicks on Carousel (Image/Title/Price)",
"addToCartClicks": "Number of clicks on \"Add to cart\" button",
"topClicked": "Top 10 Products Clicked On",
"topAdded": "Top 10 Products Added To Cart"
},
"collections": {
"carouselClicks": "Number of clicks on Carousel (Image/Title)",
"topClicked": "Top 10 Collections Clicked On"
},
"media": {
"carouselClicks": "Number of clicks on Carousel (Images/Videos)",
"topClicked": "Top 10 Images & Videos Clicked On"
},
"table": {
"productName": "Product Name",
"price": "Price",
"clicks": "Number Of Clicks",
"rank": "Rank",
"collectionName": "Collection Name",
"numProducts": "Number of products",
"mediaName": "Media Name",
"mediaType": "Media Type",
"mediaPosition": "Position In Slider"
},
"empty": {
"noProducts": {
"title": "No products found",
"description": "Try changing the date filter"
},
"noCollections": {
"title": "No collections found",
"description": "Try changing the date filter"
},
"noMedia": {
"title": "No medias found",
"description": "Try changing the date filter"
}
}
},
"support": {
"title": "Support",
"bannerTitle": "We offer a 45-day money-back guarantee, ensuring a full refund if you're not completely satisfied with our product.",
"bannerSubtitle": "We support entrepreneurs!",
"bannerDescription": "Start your online business with confidence and enjoy a 30-day free trial if your store isn't live yet!",
"contactUs": "Contact us",
"notFoundTitle": "Cant find what youre looking for?",
"notFoundDescription": "Let us know your specific needs for our carousel app, and we'll do our best to accommodate your request.",
"featureRequest": "Make a feature request",
"needHelpTitle": "Need assistance in getting a better grasp of our product?",
"documentation": "Visit our documentation"
},
"Polaris": {
"DatePicker": {
"previousMonth": "Show previous month, {previousMonthName} {showPreviousYear}",
"nextMonth": "Show next month, {nextMonth} {nextYear}",
"today": "Today ",
"start": "Start of range",
"end": "End of range",
"months": {
"january": "January",
"february": "February",
"march": "March",
"april": "April",
"may": "May",
"june": "June",
"july": "July",
"august": "August",
"september": "September",
"october": "October",
"november": "November",
"december": "December"
},
"days": {
"monday": "Monday",
"tuesday": "Tuesday",
"wednesday": "Wednesday",
"thursday": "Thursday",
"friday": "Friday",
"saturday": "Saturday",
"sunday": "Sunday"
},
"daysAbbreviated": {
"monday": "Mo",
"tuesday": "Tu",
"wednesday": "We",
"thursday": "Th",
"friday": "Fr",
"saturday": "Sa",
"sunday": "Su"
}
}
}
}

View File

@ -0,0 +1,120 @@
{
"NavBar": {
"home": "Inicio",
"analytics": "Analíticas",
"support": "Soporte",
"selectLanguage": "Cambiar idioma"
},
"welcome": {
"title": "Bienvenido {shopName}",
"description": "La solución definitiva para los comerciantes de Shopify que desean mejorar sus presentaciones de productos con carruseles dinámicos"
},
"banner": {
"compatible": "Tu tema '{themeName}' es compatible",
"incompatible": "Tu tema '{themeName}' no es compatible.",
"action": "Verificar compatibilidad del tema",
"note": "SKAPE solo es compatible actualmente con Online Store 2.0.",
"advice": "Si cambias de tema o realizas modificaciones, vuelve a verificar la compatibilidad para asegurarte de que todo funcione correctamente."
},
"videoCard": {
"title": "Un video explicativo de nuestra aplicación de carruseles",
"primary": "Comenzar a crear",
"secondary": "Más información",
"description": "En este video, aprenderás a usar SKAPE para crear un hermoso carrusel de productos dinámico en menos de un minuto."
},
"support": {
"title": "Soporte",
"bannerTitle": "Ofrecemos una garantía de devolución de dinero de 45 días, asegurando un reembolso completo si no estás completamente satisfecho con nuestro producto.",
"bannerSubtitle": "¡Apoyamos a los emprendedores!",
"bannerDescription": "Comienza tu negocio en línea con confianza y disfruta de una prueba gratuita de 30 días si tu tienda aún no está activa.",
"contactUs": "Contáctanos",
"notFoundTitle": "¿No encuentras lo que buscas?",
"notFoundDescription": "Cuéntanos tus necesidades específicas para nuestra app de carrusel, y haremos lo posible por ayudarte.",
"featureRequest": "Solicitar una función",
"needHelpTitle": "¿Necesitas ayuda para comprender mejor nuestro producto?",
"documentation": "Visita nuestra documentación"
},
"analytics": {
"title": "Analíticas",
"products": {
"carouselClicks": "Número de clics en el Carrusel (Imagen/Título/Precio)",
"addToCartClicks": "Número de clics en el botón \"Añadir al carrito\"",
"topClicked": "Top 10 Productos Más Clicados",
"topAdded": "Top 10 Productos Añadidos al Carrito"
},
"collections": {
"carouselClicks": "Número de clics en el Carrusel (Imagen/Título)",
"topClicked": "Top 10 Colecciones Más Clicadas"
},
"media": {
"carouselClicks": "Número de clics en el Carrusel (Imágenes/Vídeos)",
"topClicked": "Top 10 Imágenes y Vídeos Más Clicados"
},
"table": {
"productName": "Nombre del Producto",
"price": "Precio",
"clicks": "Número de Clics",
"rank": "Posición",
"collectionName": "Nombre de la Colección",
"numProducts": "Número de productos",
"mediaName": "Nombre del Media",
"mediaType": "Tipo de Media",
"mediaPosition": "Posición en el Slider"
},
"empty": {
"noProducts": {
"title": "No hay productos",
"description": "Intente cambiar el filtro de fecha"
},
"noCollections": {
"title": "No hay colecciones",
"description": "Intente cambiar el filtro de fecha"
},
"noMedia": {
"title": "No hay medios",
"description": "Intente cambiar el filtro de fecha"
}
}
},
"Polaris": {
"DatePicker": {
"previousMonth": "Mostrar el mes anterior, {previousMonthName} {showPreviousYear}",
"nextMonth": "Mostrar el mes siguiente, {nextMonth} {nextYear}",
"today": "Hoy ",
"months": {
"january": "enero",
"february": "febrero",
"march": "marzo",
"april": "abril",
"may": "mayo",
"june": "junio",
"july": "julio",
"august": "agosto",
"september": "septiembre",
"october": "octubre",
"november": "noviembre",
"december": "diciembre"
},
"daysAbbreviated": {
"monday": "LU",
"tuesday": "MA",
"wednesday": "MI",
"thursday": "JU",
"friday": "VI",
"saturday": "SA",
"sunday": "DO"
},
"days": {
"monday": "lunes",
"tuesday": "martes",
"wednesday": "miércoles",
"thursday": "jueves",
"friday": "viernes",
"saturday": "sábado",
"sunday": "domingo"
},
"start": "Inicio del intervalo",
"end": "Fin del intervalo"
}
}
}

View File

@ -0,0 +1,120 @@
{
"NavBar": {
"home": "Accueil",
"analytics": "Analytiques",
"support": "Support",
"selectLanguage": "Changer de langue"
},
"welcome": {
"title": "Bonjour {shopName}",
"description": "La solution ultime pour les marchands Shopify souhaitant améliorer leurs présentations produits avec des carrousels dynamiques"
},
"banner": {
"compatible": "Votre thème '{themeName}' est compatible",
"incompatible": "Votre thème '{themeName}' n'est pas compatible.",
"action": "Vérifier la compatibilité du thème",
"note": "SKAPE est actuellement pris en charge uniquement sur Online Store 2.0.",
"advice": "N'hésitez pas à vérifier la compatibilité plusieurs fois, surtout lorsque vous changez de thème ou que vous modifiez votre thème actuel, pour garantir une compatibilité continue."
},
"videoCard": {
"title": "Une vidéo explicative de notre application de carrousel",
"primary": "Commencer à créer",
"secondary": "En savoir plus",
"description": "Dans cette vidéo, vous apprendrez à utiliser SKAPE pour créer un magnifique carrousel de produits dynamique en moins d'une minute."
},
"analytics": {
"title": "Analytiques",
"products": {
"carouselClicks": "Nombre de clics sur le Carrousel (Image/Titre/Prix)",
"addToCartClicks": "Nombre de clics sur le bouton \"Ajouter au panier\"",
"topClicked": "Top 10 des Produits les Plus cliqué",
"topAdded": "Top 10 des Produits Ajoutés au Panier"
},
"collections": {
"carouselClicks": "Nombre de clics sur le Carrousel (Image/Titre)",
"topClicked": "Top 10 des Collections les Plus cliqué"
},
"media": {
"carouselClicks": "Nombre de clics sur le Carrousel (Images/Vidéos)",
"topClicked": "Top 10 des Images & Vidéos les Plus cliqué"
},
"table": {
"productName": "Nom du Produit",
"price": "Prix",
"clicks": "Nombre de Clics",
"rank": "Rang",
"collectionName": "Nom de la Collection",
"numProducts": "Nombre de produits",
"mediaName": "Nom du Média",
"mediaType": "Type de Média",
"mediaPosition": "Position dans le Slider"
},
"empty": {
"noProducts": {
"title": "Pas de produits trouvés",
"description": "Essayez de changer la date de début ou de la fin de votre recherche"
},
"noCollections": {
"title": "Pas de collections trouvées",
"description": "Essayez de changer la date de début ou de la fin de votre recherche"
},
"noMedia": {
"title": "Pas medias trouvés",
"description": "Essayez de changer la date de début ou de la fin de votre recherche"
}
}
},
"support": {
"title": "Assistance",
"bannerTitle": "Nous offrons une garantie de remboursement de 45 jours, assurant un remboursement complet si vous n'êtes pas entièrement satisfait de notre produit.",
"bannerSubtitle": "Nous soutenons les entrepreneurs !",
"bannerDescription": "Lancez votre entreprise en ligne en toute confiance et profitez d'un essai gratuit de 30 jours si votre boutique n'est pas encore en ligne !",
"contactUs": "Contactez-nous",
"notFoundTitle": "Vous ne trouvez pas ce que vous cherchez ?",
"notFoundDescription": "Faites-nous part de vos besoins spécifiques concernant notre application de carrousel, et nous ferons de notre mieux pour y répondre.",
"featureRequest": "Faire une demande de fonctionnalité",
"needHelpTitle": "Besoin d'aide pour mieux comprendre notre produit ?",
"documentation": "Consulter notre documentation"
},
"Polaris": {
"DatePicker": {
"previousMonth": "Afficher le mois précédent, {previousMonthName} {showPreviousYear}",
"nextMonth": "Afficher le mois suivant, {nextMonth} {nextYear}",
"today": "Aujourdhui ",
"months": {
"january": "Janvier",
"february": "Février",
"march": "Mars",
"april": "Avril",
"may": "Mai",
"june": "Juin",
"july": "Juillet",
"august": "Août",
"september": "Septembre",
"october": "Octobre",
"november": "Novembre",
"december": "Décembre"
},
"daysAbbreviated": {
"monday": "Lun",
"tuesday": "Mar",
"wednesday": "Mer",
"thursday": "Jeu",
"friday": "Ven",
"saturday": "Sam",
"sunday": "Dim"
},
"days": {
"monday": "Lundi",
"tuesday": "Mardi",
"wednesday": "Mercredi",
"thursday": "Jeudi",
"friday": "Vendredi",
"saturday": "Samedi",
"sunday": "Dimanche"
},
"start": "Début de la période",
"end": "Fin de la période"
}
}
}

View File

@ -0,0 +1,120 @@
{
"NavBar": {
"home": "Home",
"analytics": "Analisi",
"support": "Supporto",
"selectLanguage": "Cambia lingua"
},
"welcome": {
"title": "Benvenuto {shopName}",
"description": "La soluzione definitiva per i commercianti Shopify che vogliono migliorare le proprie vetrine con caroselli dinamici"
},
"banner": {
"compatible": "Il tuo tema '{themeName}' è compatibile",
"incompatible": "Il tuo tema '{themeName}' non è compatibile.",
"action": "Controlla la compatibilità del tema",
"note": "SKAPE è attualmente supportato solo su Online Store 2.0.",
"advice": "Verifica la compatibilità ogni volta che cambi tema o apporti modifiche per garantire la compatibilità continua."
},
"videoCard": {
"title": "Un video dimostrativo della nostra app per caroselli",
"primary": "Inizia a creare",
"secondary": "Scopri di più",
"description": "In questo video imparerai a usare SKAPE per creare un bellissimo e dinamico carosello di prodotti in meno di un minuto."
},
"analytics": {
"title": "Analisi",
"products": {
"carouselClicks": "Numero di clic sul Carosello (Immagine/Titolo/Prezzo)",
"addToCartClicks": "Numero di clic sul pulsante \"Aggiungi al carrello\"",
"topClicked": "Top 10 Prodotti Più Cliccati",
"topAdded": "Top 10 Prodotti Aggiunti al Carrello"
},
"collections": {
"carouselClicks": "Numero di clic sul Carosello (Immagine/Titolo)",
"topClicked": "Top 10 Collezioni Più Cliccate"
},
"media": {
"carouselClicks": "Numero di clic sul Carosello (Immagini/Video)",
"topClicked": "Top 10 Immagini e Video Più Cliccati"
},
"table": {
"productName": "Nome Prodotto",
"price": "Prezzo",
"clicks": "Numero di Clic",
"rank": "Posizione",
"collectionName": "Nome Collezione",
"numProducts": "Numero di prodotti",
"mediaName": "Nome Media",
"mediaType": "Tipo Media",
"mediaPosition": "Posizione nello Slider"
},
"empty": {
"noProducts": {
"title": "Non ci sono prodotti",
"description": "Prova a cambiare il filtro data"
},
"noCollections": {
"title": "Non ci sono collezioni",
"description": "Prova a cambiare il filtro data"
},
"noMedia": {
"title": "Non ci sono media",
"description": "Prova a cambiare il filtro data"
}
}
},
"support": {
"title": "Supporto",
"bannerTitle": "Offriamo una garanzia di rimborso di 45 giorni, assicurando un rimborso completo se non sei completamente soddisfatto del nostro prodotto.",
"bannerSubtitle": "Sosteniamo gli imprenditori!",
"bannerDescription": "Avvia la tua attività online con fiducia e goditi una prova gratuita di 30 giorni se il tuo negozio non è ancora attivo!",
"contactUs": "Contattaci",
"notFoundTitle": "Non riesci a trovare quello che cerchi?",
"notFoundDescription": "Faccelo sapere se hai esigenze specifiche per la nostra app di caroselli, faremo il possibile per soddisfarle.",
"featureRequest": "Richiedi una funzionalità",
"needHelpTitle": "Hai bisogno di aiuto per comprendere meglio il nostro prodotto?",
"documentation": "Visita la nostra documentazione"
},
"Polaris": {
"DatePicker": {
"previousMonth": "Mostra mese precedente, {previousMonthName} {showPreviousYear}",
"nextMonth": "Mostra mese successivo, {nextMonth} {nextYear}",
"today": "Oggi",
"months": {
"january": "Gennaio",
"february": "Febbraio",
"march": "Marzo",
"april": "Aprile",
"may": "Maggio",
"june": "Giugno",
"july": "Luglio",
"august": "Agosto",
"september": "Settembre",
"october": "Ottobre",
"november": "Novembre",
"december": "Dicembre"
},
"daysAbbreviated": {
"monday": "Lun",
"tuesday": "Mar",
"wednesday": "Mer",
"thursday": "Gio",
"friday": "Ven",
"saturday": "Sab",
"sunday": "Dom"
},
"days": {
"monday": "Lunedì",
"tuesday": "Martedì",
"wednesday": "Mercoledì",
"thursday": "Giovedì",
"friday": "Venerdì",
"saturday": "Sabato",
"sunday": "Domenica"
},
"start": "Inizio del periodo",
"end": "Fine del periodo"
}
}
}

20
web/client/src/main.js Normal file
View File

@ -0,0 +1,20 @@
import './assets/main.css'
import '@ownego/polaris-vue/dist/style.css'
// import { ShopifyAppBridge } from '@/plugins/appBridge.js'
import PolarisVue from '@ownego/polaris-vue'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { i18n } from './i18n'
console.log('[MAIN] - Global locale:', i18n.global.locale.value)
import App from './App.vue'
import router from './router'
const app = createApp(App)
// app.use(ShopifyAppBridge)
app.use(PolarisVue)
app.use(i18n)
app.use(router)
app.use(createPinia())
app.mount('#app')

View File

@ -0,0 +1,28 @@
// @ts-nocheck
import { createApp } from '@shopify/app-bridge'
export const initAppBridge = () => {
const host = new URLSearchParams(location.search).get('host') || window.__SHOPIFY_DEV_HOST
window.__SHOPIFY_DEV_HOST = host
// eslint-disable-next-line no-undef
console.log('=====> ', process.env.SHOPIFY_API_KEY)
return createApp({
// eslint-disable-next-line no-undef
apiKey: process.env.SHOPIFY_API_KEY || '',
host: host,
forceRedirect: true
})
}
export const ShopifyAppBridge = {
/**
* @param {import('vue').App} app
*/
install: (app) => {
const useAppBridge = initAppBridge()
app.config.globalProperties.$useAppBridge = useAppBridge
app.provide('useAppBridge', useAppBridge)
}
}

View File

@ -0,0 +1,46 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '@/views/HomeView.vue'
import SupportView from '@/views/SupportView.vue'
import { normalizeLocale, i18n } from '@/i18n'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/:locale?',
children: [
{
path: '',
name: 'home',
component: HomeView
},
{
path: 'support',
name: 'support',
component: SupportView
}
]
},
{
path: '/exitiframe',
name: 'exitiframe',
component: () => import('../views/ExitIframeView.vue'),
redirect: { name: 'home' }
},
{
path: '/pathMatch(.*)',
component: () => import('../views/NotFoundView.vue')
}
]
})
// i18n navigation guard
router.beforeEach(async (to, _from, next) => {
const locale = normalizeLocale(i18n.global.locale.value)
console.log('[router] Global locale:', locale)
// Proceed to the next route
next()
})
export default router

View File

@ -0,0 +1,61 @@
import { authenticatedFetch } from '@shopify/app-bridge/utilities'
import { Redirect } from '@shopify/app-bridge/actions'
import { initAppBridge } from '../plugins/appBridge'
import { i18n } from '@/i18n'
/**
* @template T
* @typedef {Response & { json: () => Promise<T> }} ShopifyResponse
*/
/**
* @param {Object} config
* @param {boolean} config.enableI18nInHeaders
* @param {HeadersInit} config.headers
* @returns {(uri: string, options?: RequestInit) => Promise<ShopifyResponse<T>>}
*/
export function useAuthenticatedFetch(config = { enableI18nInHeaders: true, headers: {} }) {
const app = initAppBridge()
const fetchFunction = authenticatedFetch(app)
/**
* @template T
* @param {string} uri
* @param {RequestInit} [options]
* @returns {Promise<ShopifyResponse<T>>}
* @throws {Error}
*/
return async (uri, options = {}) => {
const headers = new Headers({ ...config.headers, ...options.headers })
if (config.enableI18nInHeaders) {
const newLocale = i18n.global.locale.value
const browserLocales = navigator.languages.join(', ') // Get the browser's preferred languages
const updatedAcceptLanguage = `${newLocale}, ${browserLocales}`
headers.set('Accept-Language', updatedAcceptLanguage)
}
const response = await fetchFunction(uri, { ...options, headers })
checkHeadersForReauthorization(response.headers, app)
return response
}
}
/**
* Checks headers for reauthorization and redirects if necessary.
* @param {Headers} headers
* @param {any} app
*/
function checkHeadersForReauthorization(headers, app) {
if (headers.get('X-Shopify-API-Request-Failure-Reauthorize') === '1') {
const authUrlHeader =
headers.get('X-Shopify-API-Request-Failure-Reauthorize-Url') || `/api/auth`
const redirect = Redirect.create(app)
redirect.dispatch(
Redirect.Action.REMOTE,
authUrlHeader.startsWith('/')
? `https://${window.location.host}${authUrlHeader}`
: authUrlHeader
)
}
}

View File

@ -0,0 +1,23 @@
import { defineStore } from 'pinia'
export const useSubscriptionStore = defineStore('subscription', {
state: () => ({
currentSubscription: null
}),
actions: {
setCurrentSubscription(subscription) {
this.currentSubscription = subscription
},
async fetchCurrentSubscription() {
try {
const response = await fetch('/api/billing')
const data = await response.json()
this.setCurrentSubscription(data.currentSubscription)
} catch (error) {
console.error('Failed to fetch subscription:', error)
}
}
},
persist: true
})

View File

@ -0,0 +1,19 @@
// shopify.store.js
import { defineStore } from 'pinia'
import { ShopifyAppBridge } from '@/plugins/appBridge.js'
const useAppBridge = ShopifyAppBridge.initAppBridge()
export const useShopifyStore = defineStore('shopify', {
state: () => ({
appBridge: useAppBridge
}),
actions: {
async getProductsCount() {
console.log('App bridge called from store function')
// const response = await this.appBridge.get('/api/products/count')
// const data = await response.json()
// return data.productsCount.count
}
}
})

View File

@ -0,0 +1,23 @@
<template>
<Page>
<BlockStack gap="500">
<Layout>
<LayoutSection>
<Card>
<BlockStack gap="500" align="end">
<BlockStack gap="50" inline-align="center">
<Text as="h2" variant="headingMd"
>Your store is disconnected from our app, reconnecting...</Text
>
</BlockStack>
</BlockStack>
</Card>
</LayoutSection>
</Layout>
</BlockStack>
</Page>
</template>
<script setup></script>
<style scoped></style>

View File

@ -0,0 +1,9 @@
<script setup>
import WelcomeOnboarding from '@/components/Home/WelcomeOnboarding.vue'
</script>
<template>
<main>
<WelcomeOnboarding />
</main>
</template>

View File

@ -0,0 +1,10 @@
<template>
<div>
<h1>404</h1>
<p>Page not found</p>
</div>
</template>
<script setup></script>
<style scoped></style>

View File

@ -0,0 +1,9 @@
<template>
<main>
<SupportDashboard />
</main>
</template>
<script setup>
import SupportDashboard from '@/components/Support/SupportDashboard.vue'
</script>

75
web/client/vite.config.js Normal file
View File

@ -0,0 +1,75 @@
/* eslint-disable no-undef */
import { defineConfig } from 'vite'
import { dirname } from 'path'
import { fileURLToPath } from 'url'
import vue from '@vitejs/plugin-vue'
import svgLoader from 'vite-svg-loader'
if (
process.env.npm_lifecycle_event === 'build' &&
!process.env.CI &&
!process.env.SHOPIFY_API_KEY
) {
console.warn(
'\nBuilding the frontend app without an API key. The frontend build will not run without an API key. Set the SHOPIFY_API_KEY environment variable when running the build command.\n'
)
}
const proxyOptions = {
target: `http://127.0.0.1:${process.env.BACKEND_PORT}`,
changeOrigin: false,
secure: true,
ws: false
}
const host = process.env.HOST ? process.env.HOST.replace(/https?:\/\//, '') : 'localhost'
let hmrConfig
if (host === 'localhost') {
hmrConfig = {
protocol: 'ws',
host: 'localhost',
port: 64999,
clientPort: 64999
}
} else {
hmrConfig = {
protocol: 'wss',
host: host,
port: process.env.FRONTEND_PORT,
clientPort: 443
}
}
export default defineConfig({
root: dirname(fileURLToPath(import.meta.url)),
plugins: [
vue({
template: {
compilerOptions: {
// Treat appBridge web components that start with `ui-` as custom elements
isCustomElement: (tag) => tag.startsWith('ui-')
}
}
}),
svgLoader()
],
define: {
'process.env.SHOPIFY_API_KEY': JSON.stringify(process.env.SHOPIFY_API_KEY)
},
resolve: {
preserveSymlinks: true,
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
host: 'localhost',
port: process.env.FRONTEND_PORT,
hmr: hmrConfig,
proxy: {
'^/(\\?.*)?$': proxyOptions,
'^/api(/|(\\?.*)?$)': proxyOptions
}
}
})

32
web/server/package.json Normal file
View File

@ -0,0 +1,32 @@
{
"name": "server",
"version": "2.0.0",
"private": true,
"license": "MIT",
"packageManager": "npm",
"main": "src/index.js",
"scripts": {
"debug": "node --inspect-brk src/index.js",
"dev": "cross-env NODE_ENV=development nodemon src/index.js --ignore ../client",
"serve": "cross-env NODE_ENV=production node src/index.js"
},
"type": "module",
"engines": {
"node": ">=18.0.0"
},
"dependencies": {
"@shopify/shopify-app-express": "^5.0.10",
"@shopify/shopify-app-session-storage-postgresql": "^4.0.17",
"compression": "^1.7.5",
"cors": "^2.8.5",
"cross-env": "^7.0.3",
"pg": "^8.16.0",
"serve-static": "^1.16.2"
},
"devDependencies": {
"dotenv": "^16.4.7",
"nodemon": "^3.1.9",
"prettier": "^3.4.2",
"pretty-quick": "^4.0.0"
}
}

View File

@ -0,0 +1,4 @@
type="backend"
[commands]
dev = "npm run dev"

View File

@ -0,0 +1,15 @@
-- SQLite Create Stats Table
drop table IF EXISTS stats;
CREATE TABLE stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
shop TEXT NOT NULL,
userId INTEGER,
action TEXT NOT NULL,
createdAt TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updatedAt TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
productId TEXT,
productImage TEXT,
productPrice TEXT,
productTitle TEXT,
productUrl TEXT
);

View File

@ -0,0 +1,19 @@
-- SQLite Stats insert
INSERT INTO stats (
shop, userId, action, createdAt, updatedAt, productId, productImage, productPrice, productTitle, productUrl
) VALUES
('quickstart-64437001.myshopify.com', NULL, 'details', '2025-02-01 18:40:25+00', '2025-02-01 18:40:25.745+00', '7692710052035', '//quickstart-64437001.myshopify.com/cdn/shop/products/Main_9129b69a-0c7b-4f66-b6cf-c4222f18028a.jpg?v=1702763724&width=370', '€629,95', 'The Multi-managed Snowboard', '/products/the-multi-managed-snowboard'),
('quickstart-64437001.myshopify.com', NULL, 'details', '2025-02-02 18:09:38.999+00', '2025-02-02 18:09:38.999+00', '42736183804099', '//quickstart-64437001.myshopify.com/cdn/shop/products/Main_b9e0da7f-db89-4d41-83f0-7f417b02831d.jpg?v=1702763724&width=370', '€2.629,95', 'The 3p Fulfilled Snowboard', '/products/the-3p-fulfilled-snowboard'),
('quickstart-64437001.myshopify.com', NULL, 'addToCart', '2025-02-03 18:49:15.672+00', '2025-02-03 18:49:15.672+00', '7692710052035', '//quickstart-64437001.myshopify.com/cdn/shop/products/Main_9129b69a-0c7b-4f66-b6cf-c4222f18028a.jpg?v=1702763724&width=370', '€629,95', 'The Multi-managed Snowboard', '/products/the-multi-managed-snowboard'),
('quickstart-64437001.myshopify.com', NULL, 'details', '2025-02-04 18:09:43.454+00', '2025-02-04 18:09:43.454+00', '42736183902403', '//quickstart-64437001.myshopify.com/cdn/shop/products/snowboard_wax.png?v=1702763727&width=370', '€9,95', 'Selling Plans Ski Wax', '/products/selling-plans-ski-wax'),
('quickstart-64437001.myshopify.com', NULL, 'details', '2025-02-05 18:29:48.571+00', '2025-02-05 18:29:48.571+00', '7692710150339', '//quickstart-64437001.myshopify.com/cdn/shop/products/Main_b13ad453-477c-4ed1-9b43-81f3345adfd6.jpg?v=1702763726&width=370', '€749,95', 'The Collection Snowboard: Liquid', '/products/the-collection-snowboard-liquid'),
('quickstart-64437001.myshopify.com', NULL, 'details', '2025-02-06 18:09:47.92+00', '2025-02-06 18:09:47.92+00', '42736183804099', '//quickstart-64437001.myshopify.com/cdn/shop/products/Main_b9e0da7f-db89-4d41-83f0-7f417b02831d.jpg?v=1702763724&width=370', '€2.629,95', 'The 3p Fulfilled Snowboard', '/products/the-3p-fulfilled-snowboard'),
('quickstart-64437001.myshopify.com', NULL, 'details', '2025-02-07 18:09:35.736+00', '2025-02-07 18:09:35.736+00', '42736182722755', '//quickstart-64437001.myshopify.com/cdn/shop/products/Main_0a40b01b-5021-48c1-80d1-aa8ab4876d3d.jpg?v=1702763723&width=370', '€600,00', 'The Collection Snowboard: Hydrogen', '/products/the-collection-snowboard-hydrogen'),
('quickstart-64437001.myshopify.com', NULL, 'addToCart', '2025-02-08 18:47:45.592+00', '2025-02-08 18:47:45.592+00', '7692710052035', '//quickstart-64437001.myshopify.com/cdn/shop/products/Main_9129b69a-0c7b-4f66-b6cf-c4222f18028a.jpg?v=1702763724&width=370', '€629,95', 'The Multi-managed Snowboard', '/products/the-multi-managed-snowboard'),
('quickstart-64437001.myshopify.com', NULL, 'addToCart', '2025-02-09 18:47:59.458+00', '2025-02-09 18:47:59.458+00', '7692710052035', '//quickstart-64437001.myshopify.com/cdn/shop/products/Main_9129b69a-0c7b-4f66-b6cf-c4222f18028a.jpg?v=1702763724&width=370', '€629,95', 'The Multi-managed Snowboard', '/products/the-multi-managed-snowboard'),
('quickstart-64437001.myshopify.com', NULL, 'addToCart', '2025-02-10 18:33:00.541+00', '2025-02-10 18:33:00.541+00', '42736183902403', '//quickstart-64437001.myshopify.com/cdn/shop/products/snowboard_wax.png?v=1702763727&width=370', '€9,95', 'Selling Plans Ski Wax', '/products/selling-plans-ski-wax'),
('quickstart-64437001.myshopify.com', NULL, 'details', '2025-02-11 18:40:25.739+00', '2025-02-11 18:40:25.739+00', '7692710052035', '//quickstart-64437001.myshopify.com/cdn/shop/products/Main_9129b69a-0c7b-4f66-b6cf-c4222f18028a.jpg?v=1702763724&width=370', '€629,95', 'The Multi-managed Snowboard', '/products/the-multi-managed-snowboard'),
('quickstart-64437001.myshopify.com', NULL, 'details', '2025-02-12 18:09:41.142+00', '2025-02-12 18:09:41.142+00', '42736183050435', '//quickstart-64437001.myshopify.com/cdn/shop/products/gift_card.png?v=1702763723&width=370', '€10,00', 'Gift Card', '/products/gift-card'),
('quickstart-64437001.myshopify.com', NULL, 'addToCart', '2025-02-13 18:49:24.158+00', '2025-02-13 18:49:24.158+00', '7692710052035', '//quickstart-64437001.myshopify.com/cdn/shop/products/Main_9129b69a-0c7b-4f66-b6cf-c4222f18028a.jpg?v=1702763724&width=370', '€629,95', 'The Multi-managed Snowboard', '/products/the-multi-managed-snowboard'),
('quickstart-64437001.myshopify.com', NULL, 'addToCart', '2025-02-14 18:40:10.45+00', '2025-02-14 18:40:10.45+00', '7692710084803', '//quickstart-64437001.myshopify.com/cdn/shop/products/Main_b9e0da7f-db89-4d41-83f0-7f417b02831d.jpg?v=1702763724&width=370', '€2.629,95', 'The 3p Fulfilled Snowboard', '/products/the-3p-fulfilled-snowboard');

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,22 @@
import { Pool } from 'pg'
import { User } from '../models/postgresql/user.js'
import { Webhook } from '../models/postgresql/webhooks.js'
export async function initDatabase() {
const client = new Pool({ connectionString: process.env.DATABASE_URL })
try {
await client.connect()
client.on('error', async (err) => {
console.error('An unexpected DB error has happened while running => ', err.stack)
await client.end()
})
Webhook.init(client)
User.init(client)
} catch (err) {
console.error('Failed to connect to database', err)
throw err
}
}

83
web/server/src/index.js Normal file
View File

@ -0,0 +1,83 @@
// @ts-check
import { join } from 'path'
import { readFileSync } from 'fs'
import express from 'express'
import cors from 'cors'
import serveStatic from 'serve-static'
import shopify from './shopify.js'
import WebhookHandlers from './webhooks/webhooks.js'
import shopRoutes from './routes/shop.js'
import billingRoutes from './routes/billing.js'
import { validateWebhookRequest } from './utils/webhook-utils.js'
import { registerUser } from './middlewares/user-middleware.js'
import 'dotenv/config'
const PORT = parseInt(process.env.BACKEND_PORT || process.env.PORT || '3000', 10)
const STATIC_PATH =
process.env.NODE_ENV === 'production'
? `${process.cwd()}/client/dist`
: `${process.cwd()}/../client/`
const app = express()
// Webhook handling need raw input for Shopify to work
app.use(express.raw({ type: 'application/json' }))
// Set up Shopify authentication and webhook handling
app.get(shopify.config.auth.path, shopify.auth.begin())
app.get(
shopify.config.auth.callbackPath,
shopify.auth.callback(),
registerUser,
shopify.redirectToShopifyOrAppRoot()
)
app.post(
shopify.config.webhooks.path,
express.raw({ type: '*/*' }),
validateWebhookRequest,
shopify.processWebhooks({ webhookHandlers: WebhookHandlers })
)
// ===============================================================
// If you are adding routes outside of the /api path, remember to
// also add a proxy rule for them in web/client/vite.config.js
// ===============================================================
app.use(express.json())
// CORS: only allow POST/OPTIONS requests from any origin
const corsOptions = {
origin: '*',
methods: ['POST', 'OPTIONS'],
allowedHeaders: ['Content-Type'],
credentials: false
}
// Handle preflight requests explicitly (required for browser preflight)
app.options('*', cors(corsOptions))
app.use('/api/*', (req, res, next) => {
console.log(`>>>>> Request ${req.method} to ${req.baseUrl} received`)
// Skip authentication if it's POST to /api/stats
// if ((req.method === 'POST' || req.method === 'OPTIONS') && req.baseUrl === '/api/stats') {
// console.log('>>>>> Skipping authentication for POST to /api/stats')
// console.log('>> BODY RECEIVED:', req.body)
// return next()
// }
return shopify.validateAuthenticatedSession()(req, res, next)
})
app.use('/api/shop', shopRoutes)
app.use('/api/billing', billingRoutes)
app.use(shopify.cspHeaders())
app.use(serveStatic(STATIC_PATH, { index: false }))
app.use('/*', shopify.ensureInstalledOnShop(), (_req, res) => {
console.log(`[ensureInstalledOnShop()] Returning ROOT index.html file...`)
// Return the index.html file for the root URL
let htmlContent = readFileSync(join(STATIC_PATH, 'index.html'), 'utf8')
res.status(200).set('Content-Type', 'text/html').send(htmlContent)
})
app.listen(PORT)

View File

@ -0,0 +1,19 @@
import { User } from '../models/postgresql/user.js'
// create a user after installing the app
export async function registerUser(req, res, next) {
try {
const { shop } = req.query
console.log('>>>>>>>>>>>>>>>>>>>>>>')
console.log('>> registerUser', req.query)
console.log('<<<<<<<<<<<<<<<<<<<<<<')
await User.create({ shop })
next()
} catch (error) {
console.log(error)
// ! fails silently
next(error)
}
}

View File

@ -0,0 +1,108 @@
export const User = {
userTableName: 'users',
db: null,
ready: null,
create: async function ({ shop }) {
await this.ready
const existingUser = await this.read(shop)
if (existingUser) return existingUser
const query = `INSERT INTO ${this.userTableName} (shop) VALUES ($1) RETURNING *`
const rows = await this.__query(query, [shop])
return rows[0]
},
update: async function (shop, fields) {
await this.ready
const keys = Object.keys(fields)
if (keys.length === 0) {
throw new Error('No fields provided for update')
}
const setClauses = keys.map((key, index) => `${key} = $${index + 1}`)
const values = Object.values(fields)
const query = `
UPDATE ${this.userTableName}
SET ${setClauses.join(', ')}, updated_at = NOW()
WHERE shop = $${keys.length + 1}
`
await this.__query(query, [...values, shop])
return true
},
read: async function (shop) {
await this.ready
const query = `SELECT * FROM ${this.userTableName} WHERE shop = $1`
const rows = await this.__query(query, [shop])
return rows[0]
},
delete: async function (shop) {
await this.ready
const query = `DELETE FROM ${this.userTableName} WHERE shop = $1`
await this.__query(query, [shop])
return true
},
__hasUserTable: async function () {
const query = `
SELECT table_name FROM information_schema.tables
WHERE table_name = $1 AND table_schema = 'public'
`
const rows = await this.__query(query, [this.userTableName])
return rows.length === 1
},
init: async function (client) {
console.log(`Initiating Dev User Model: ${this.userTableName}`)
this.db = client
if (!this.db) throw new Error('Database client not provided')
const userTable = await this.__hasUserTable()
if (userTable) {
this.ready = Promise.resolve()
} else {
const query = `
CREATE TABLE ${this.userTableName} (
id SERIAL PRIMARY KEY,
shop VARCHAR(511) NOT NULL UNIQUE,
shop_id VARCHAR(255),
shop_name VARCHAR(511),
myshopify_domain VARCHAR(511),
primary_domain_url VARCHAR(511),
email VARCHAR(511),
shop_owner_name VARCHAR(511),
currency_code VARCHAR(10),
timezone_abbreviation VARCHAR(10),
timezone_offset VARCHAR(10),
plan_partner_development BOOLEAN,
plan_shopify_plus BOOLEAN,
billing_address1 VARCHAR(511),
billing_address2 VARCHAR(511),
billing_city VARCHAR(255),
billing_country VARCHAR(255),
supported_digital_wallets TEXT[],
ships_to_countries TEXT[],
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
)
`
this.ready = this.__query(query)
}
},
__query: async function (sql, params = []) {
try {
const result = await this.db.query(sql, params)
return result.rows
} catch (err) {
console.error('Query failed:', err.message)
throw err
}
}
}

View File

@ -0,0 +1,76 @@
export const Webhook = {
table: 'webhooks',
db: null,
ready: null,
init: async function (client) {
console.log('Initializing PostgreSQL DB: webhooks')
this.db = client
const exists = await this.__hasWebhooksTable()
if (!exists) {
const query = `
CREATE TABLE ${this.table} (
id SERIAL PRIMARY KEY,
webhook_id TEXT NOT NULL,
webhook_topic TEXT NOT NULL,
shop TEXT NOT NULL,
timestamp TEXT NOT NULL,
processed BOOLEAN NOT NULL,
user_id INTEGER,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`
await this.db.query(query)
}
this.ready = Promise.resolve()
},
__hasWebhooksTable: async function () {
const query = `
SELECT to_regclass($1) as table_exists
`
const res = await this.db.query(query, [this.table])
return res.rows[0].table_exists !== null
},
create: async function ({ user_id, webhook_id, webhook_topic, shop, timestamp, processed }) {
await this.ready
const query = `
INSERT INTO ${this.table} (
user_id,
webhook_id,
webhook_topic,
shop,
timestamp,
processed
) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id
`
const values = [user_id, webhook_id, webhook_topic, shop, timestamp, processed]
const res = await this.db.query(query, values)
console.log('Webhook created:', res.rows[0].id)
return res.rows[0].id
},
read: async function (webhookId) {
await this.ready
const query = `SELECT * FROM ${this.table} WHERE webhook_id = $1`
const res = await this.db.query(query, [webhookId])
const row = res.rows[0]
console.log('Webhook read:', row)
return row
},
update: async function (webhookId, updates) {
await this.ready
const keys = Object.keys(updates)
const values = Object.values(updates)
const setClause = keys.map((key, i) => `${key} = $${i + 1}`).join(', ')
const query = `UPDATE ${this.table} SET ${setClause} WHERE webhook_id = $${keys.length + 1}`
await this.db.query(query, [...values, webhookId])
console.log('Webhook updated:', webhookId)
}
}

View File

@ -0,0 +1,38 @@
import express from 'express'
import shopify from '../shopify.js'
import getCurrentSubscription from '../services/shopify/billing.js'
import { GraphqlQueryError } from '@shopify/shopify-api'
const router = express.Router()
router.get('/', async (req, res) => {
const session = res.locals.shopify.session
const client = new shopify.api.clients.Graphql({ session })
try {
const currentSubscription = await getCurrentSubscription(client)
console.log('===router.get(/billing/)===> ', currentSubscription)
res.status(200).send({ currentSubscription })
} catch (error) {
if (error instanceof GraphqlQueryError) {
console.error(`${error.message}\n${JSON.stringify(error.response, null, 2)}`)
}
res.status(500).send({ error: 'Failed to retrieve current subscription: ' + error })
}
})
router.get('/manage', async (req, res) => {
const session = res.locals.shopify.session
const shophandle = session.shop.split('.')[0]
const appHandle = process.env.SHOPIFY_APP_HANDLE
console.log('===router.get(/manage/)===> ', appHandle)
const managedPricingUrl = `https://admin.shopify.com/store/${shophandle}/charges/${appHandle}/pricing_plans`
shopify.redirectOutOfApp({ req, res, redirectUri: managedPricingUrl, shop: session.shop })
// res.redirect(managedPricingUrl)
// res.status(200).send({ managedPricingUrl })
// /?charge_id=56701649226
})
export default router

View File

@ -0,0 +1,75 @@
import express from 'express'
import shopify from '../shopify.js'
import { User } from '../models/postgresql/user.js'
import { getShopName, getShopInformations } from '../services/shopify/shop.js'
import { GraphqlQueryError } from '@shopify/shopify-api'
const router = express.Router()
router.get('/name', async (req, res) => {
const session = res.locals.shopify.session
const client = new shopify.api.clients.Graphql({ session })
console.log('===router.get(/shop/name)-session===> ', session)
try {
// const shopName = await getShopName(client)
const infos = await getShopInformations(client)
const shopName = infos.name
console.log('===router.get(/shop/name)===> ', shopName)
// Update shop information - SHOULD STAY NON BLOCKING
const canUpdate = await isUserUpdated(session.shop)
if (canUpdate) {
// const infos = await getShopInformations(client)
// console.log('===router.get(/shop/name)-isUserUpdated===> ', infos)
User.update(session.shop, {
shop_id: infos.id,
shop_name: infos.name,
myshopify_domain: infos.myshopifyDomain,
primary_domain_url: infos.primaryDomain?.url ?? null,
email: infos.email,
shop_owner_name: infos.shopOwnerName,
currency_code: infos.currencyCode,
timezone_abbreviation: infos.timezoneAbbreviation,
timezone_offset: infos.timezoneOffset,
plan_partner_development: infos.plan?.partnerDevelopment ?? null,
plan_shopify_plus: infos.plan?.shopifyPlus ?? null,
billing_address1: infos.billingAddress?.address1 ?? null,
billing_address2: infos.billingAddress?.address2 ?? null,
billing_city: infos.billingAddress?.city ?? null,
billing_country: infos.billingAddress?.country ?? null,
supported_digital_wallets: infos.paymentSettings?.supportedDigitalWallets ?? [],
ships_to_countries: infos.shipsToCountries ?? []
})
}
res.status(200).send({ shopName: shopName })
} catch (error) {
if (error instanceof GraphqlQueryError) {
console.error(`${error.message}\n${JSON.stringify(error.response, null, 2)}`)
}
res.status(500).send({ error: 'Failed to retrieve current shop name' })
}
})
const isUserUpdated = async (shop) => {
const user = await User.read(shop)
console.log('===isUserUpdated===> ', user)
return user.shop_id !== null ? false : true
}
// router.get('/infos', async (req, res) => {
// const session = res.locals.shopify.session
// const client = new shopify.api.clients.Graphql({ session })
// try {
// const shopName = await getShopInformations(client)
// console.log('===router.get(/shop/name)===> ', shopName)
// res.status(200).send({ shopInformations: shopName })
// } catch (error) {
// if (error instanceof GraphqlQueryError) {
// console.error(`${error.message}\n${JSON.stringify(error.response, null, 2)}`)
// }
// res.status(500).send({ error: 'Failed to retrieve current shop name' })
// }
// })
export default router

View File

@ -0,0 +1,34 @@
import { GraphqlQueryError } from '@shopify/shopify-api'
export default async function getCurrentSubscription(client) {
try {
return await currentSubscription(client)
} catch (error) {
if (error instanceof GraphqlQueryError) {
throw new Error(`${error.message}\n${JSON.stringify(error.response, null, 2)}`)
} else {
console.log(new Error(`${error.message}\n${JSON.stringify(error.response, null, 2)}`))
throw error
}
}
}
const currentSubscription = async (graphqlClient) => {
const query = `
{
currentAppInstallation {
activeSubscriptions {
id
name
status
createdAt
currentPeriodEnd
test
}
}
}
`
const response = await graphqlClient.request(query)
return response.data.currentAppInstallation.activeSubscriptions[0]
}

View File

@ -0,0 +1,77 @@
import { GraphqlQueryError } from '@shopify/shopify-api'
export async function getShopName(client) {
try {
return await shopName(client)
} catch (error) {
if (error instanceof GraphqlQueryError) {
throw new Error(`${error.message}\n${JSON.stringify(error.response, null, 2)}`)
} else {
console.log(new Error(`${error.message}\n${JSON.stringify(error.response, null, 2)}`))
throw error
}
}
}
export async function getShopInformations(client) {
try {
return await shopInformations(client)
} catch (error) {
if (error instanceof GraphqlQueryError) {
throw new Error(`${error.message}\n${JSON.stringify(error.response, null, 2)}`)
} else {
console.log(new Error(`${error.message}\n${JSON.stringify(error.response, null, 2)}`))
throw error
}
}
}
const shopName = async (graphqlClient) => {
const query = `
{
shop {
name
}
}
`
const response = await graphqlClient.request(query)
return response.data.shop.name
}
const shopInformations = async (graphqlClient) => {
const query = `
{
shop {
id
name
myshopifyDomain
primaryDomain {
url
}
email
shopOwnerName
currencyCode
timezoneAbbreviation
timezoneOffset
plan {
partnerDevelopment
shopifyPlus
}
billingAddress {
address1
address2
city
country
}
paymentSettings {
supportedDigitalWallets
}
shipsToCountries
}
}
`
const response = await graphqlClient.request(query)
return response.data.shop
}

31
web/server/src/shopify.js Normal file
View File

@ -0,0 +1,31 @@
import { LATEST_API_VERSION } from '@shopify/shopify-api'
import { shopifyApp } from '@shopify/shopify-app-express'
import { PostgreSQLSessionStorage } from '@shopify/shopify-app-session-storage-postgresql'
import { initDatabase } from './database/database.js'
import dotenv from 'dotenv'
// Checking environment variables
dotenv.config()
if (!process.env.DATABASE_URL) {
throw new Error('Missing `DATABASE_URL` environment variable')
}
// Initialize the database
await initDatabase()
// Initialize the Shopify app
const shopify = shopifyApp({
api: {
apiVersion: process.env.SHOPIFY_API_VERSION || LATEST_API_VERSION
},
auth: {
path: '/api/auth',
callbackPath: '/api/auth/callback'
},
webhooks: {
path: '/api/webhooks'
},
sessionStorage: new PostgreSQLSessionStorage(new URL(process.env.DATABASE_URL))
})
export default shopify

View File

@ -0,0 +1,16 @@
/**
*
* @param {Request} request
* @returns {{user_preferred_language: string, browser_language: string, secondary_language: string, tertiary_language: string}}
*/
export function getLocalePreferencesFromRequest(request) {
const acceptLang = request.headers.get('accept-language')
const languages = acceptLang.split(',').map((lang) => lang.trim())
return {
user_preferred_language: languages[0] || null,
browser_language: languages[1] || null,
secondary_language: languages[2] || null,
tertiary_language: languages[3] || null
}
}

View File

@ -0,0 +1,80 @@
import crypto from 'crypto'
import { ShopifyHeader } from '@shopify/shopify-api'
import { User } from '../models/postgresql/user.js'
import { Webhook } from '../models/postgresql/webhooks.js'
/**
* @description Validates incoming webhook requests, ensuring they are from Shopify.
* @success 200 OK is sent if the request is valid.
* @error 401 Unauthorized otherwise.
*/
export function validateWebhookRequest(req, res, next) {
try {
console.log('Validating Webhook')
console.log('Listening to webhook API VERSION:', req.get(ShopifyHeader.ApiVersion))
const generatedHash = crypto
.createHmac('SHA256', process.env.SHOPIFY_API_SECRET)
.update(req.body, 'utf8')
.digest('base64')
const hmac = req.get(ShopifyHeader.Hmac) // Equal to 'X-Shopify-Hmac-Sha256' at the time of coding
const generatedBuffer = Buffer.from(generatedHash, 'base64')
const hmacBuffer = Buffer.from(hmac, 'base64')
const safeCompareResult = crypto.timingSafeEqual(generatedBuffer, hmacBuffer)
if (safeCompareResult) {
console.log('HMAC validation successful: Sending 200 OK')
res.status(200)
next()
} else {
return res.status(401).json({ succeeded: false, message: 'Not Authorized' }).send()
}
} catch (error) {
console.log(error)
return res.status(401).json({ succeeded: false, message: 'Error caught' }).send()
}
}
/**
* @template T - The payload returned from the webhook. See https://shopify.dev/docs/api/webhooks?reference=toml#list-of-topics for more information.
* @description Processes incoming webhook requests from Shopify. The function performs the following steps:
* - Captures the webhook request.
* - Parses the raw request body into a JSON object.
* - Checks if the webhook has already been processed to prevent duplicate handling.
* - Stores the webhook details in the database using the Webhook ID.
* - Records the shop that fired the webhook and the timestamp of the event.
* @param {string} topic - The topic of the webhook event.
* @param {string} shop - The shop domain from which the webhook originated.
* @param {ArrayBuffer} body - The raw body of the webhook request.
* @param {string} webhookId - The unique identifier for the webhook.
* @returns {Promise<T>} - Returns the parsed payload if successful.
* @throws {Error} - Throws an error if the webhook is a duplicate or if any processing error occurs.
*/
export async function processWebhook(topic, shop, body, webhookId) {
console.log(`************* STARTED ${topic} *************`)
try {
const user = await User.read(shop)
const webhooks = await Webhook.read(webhookId)
if (webhooks) {
throw new Error('>Duplicate webhook received')
}
await Webhook.create({
webhook_id: webhookId,
webhook_topic: topic,
shop,
timestamp: Date.now(),
processed: false,
user_id: user === undefined ? null : user.id
})
const bodyString = body.toString('utf8')
const payload = JSON.parse(bodyString)
return payload
} catch (error) {
console.error('> An error occurred during webhook processing:', error)
return
} finally {
await Webhook.update(webhookId, { processed: true })
console.log(`************* ENDED ${topic} *************`)
}
}

View File

@ -0,0 +1,117 @@
import { DeliveryMethod } from '@shopify/shopify-api'
import { processWebhook } from '../utils/webhook-utils.js'
import { User } from '../models/postgresql/user.js'
/**
* Add your webhook handlers here. The key should be the topic of the webhook
* @see https://shopify.dev/docs/api/admin-graphql/2025-01/enums/webhooksubscriptiontopic
*/
/**************************************************************
* NOTE: GDPR Webhooks are mandatory for public apps in the EU.
***************************************************************/
/**
* @type {{[key: string]: import("@shopify/shopify-api").WebhookHandler}}
*/
export default {
/**
* Customers can request their data from a store owner. When this happens,
* Shopify invokes this webhook.
* https://shopify.dev/docs/apps/webhooks/configuration/mandatory-webhooks#customers-data_request
*/
CUSTOMERS_DATA_REQUEST: {
deliveryMethod: DeliveryMethod.Http,
callbackUrl: '/api/webhooks',
callback: async (topic, shop, body, webhookId) => {
const payload = await processWebhook(topic, shop, body, webhookId)
console.log('>> Customer data redact: ', payload)
// Payload has the following shape:
// {
// "shop_id": 954889,
// "shop_domain": "{shop}.myshopify.com",
// "orders_requested": [
// 299938,
// 280263,
// 220458
// ],
// "customer": {
// "id": 191167,
// "email": "john@example.com",
// "phone": "555-625-1199"
// },
// "data_request": {
// "id": 9999
// }
// }
}
},
/**
* Store owners can request that data is deleted on behalf of a customer. When
* this happens, Shopify invokes this webhook.
*
* https://shopify.dev/docs/apps/webhooks/configuration/mandatory-webhooks#customers-redact
*/
CUSTOMERS_REDACT: {
deliveryMethod: DeliveryMethod.Http,
callbackUrl: '/api/webhooks',
callback: async (topic, shop, body, webhookId) => {
const payload = await processWebhook(topic, shop, body, webhookId)
console.log('>> Customer redact: ', payload)
// Payload has the following shape:
// {
// "shop_id": 954889,
// "shop_domain": "{shop}.myshopify.com",
// "customer": {
// "id": 191167,
// "email": "john@example.com",
// "phone": "555-625-1199"
// },
// "orders_to_redact": [
// 299938,
// 280263,
// 220458
// ]
// }
}
},
/**
* 48 hours after a store owner uninstalls your app, Shopify invokes this
* webhook.
*
* https://shopify.dev/docs/apps/webhooks/configuration/mandatory-webhooks#shop-redact
*/
SHOP_REDACT: {
deliveryMethod: DeliveryMethod.Http,
callbackUrl: '/api/webhooks',
callback: async (topic, shop, body, webhookId) => {
const payload = await processWebhook(topic, shop, body, webhookId)
// Payload has the following shape:
// {
// "shop_id": 954889,
// "shop_domain": "{shop}.myshopify.com"
// }
console.log('>> Shop redact: ', payload)
}
},
APP_UNINSTALLED: {
deliveryMethod: DeliveryMethod.Http,
callbackUrl: '/api/webhooks',
callback: async (topic, shop, body, webhookId) => {
const payload = await processWebhook(topic, shop, body, webhookId)
console.log('>> App Uninstalled(payload): ', payload)
console.log('>> App Uninstalled(shop): ', shop)
const user = await User.read(shop)
console.log('>> [APP_UNINSTALLED] User: ', user)
if (user) {
await User.delete(shop)
console.log('>> [APP_UNINSTALLED] User deleted: ', user)
} else {
console.log('>> [APP_UNINSTALLED] User not deleted: ', user)
}
}
}
}