Initial commit
This commit is contained in:
commit
593e1928b3
10
.dockerignore
Normal file
10
.dockerignore
Normal 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
32
.gitignore
vendored
Normal 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
3
.npmrc
Normal file
@ -0,0 +1,3 @@
|
||||
engine-strict=true
|
||||
auto-install-peers=true
|
||||
shamefully-hoist=true
|
||||
9
.prettierrc.json
Normal file
9
.prettierrc.json
Normal 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
37
Dockerfile
Normal 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
21
LICENSE.md
Normal 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
256
README.md
Normal file
@ -0,0 +1,256 @@
|
||||
# Shopify App Template Using Vue v.2 🟢
|
||||
|
||||
[](https://madewithvuejs.com/p/shopify-vue-app-template/shield-link)
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## App Submission
|
||||
|
||||
Built an app using this template? Submit it here: [App submission form](https://forms.gle/K8VGCqvcvfBRSug58).
|
||||
|
||||
36
SECURITY.md
Normal file
36
SECURITY.md
Normal 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
35
eslint.config.mjs
Normal 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
11
jsconfig.json
Normal 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
50
package.json
Normal 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
28
web/client/.gitignore
vendored
Normal 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
58
web/client/README.md
Normal 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
16
web/client/index.html
Normal 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
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
30
web/client/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
web/client/public/favicon.ico
Normal file
BIN
web/client/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
5
web/client/shopify.web.toml
Normal file
5
web/client/shopify.web.toml
Normal 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
102
web/client/src/App.vue
Normal 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>
|
||||
351
web/client/src/assets/base.css
Normal file
351
web/client/src/assets/base.css
Normal 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;
|
||||
}
|
||||
BIN
web/client/src/assets/images/home-trophy-vue.png
Normal file
BIN
web/client/src/assets/images/home-trophy-vue.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
36
web/client/src/assets/main.css
Normal file
36
web/client/src/assets/main.css
Normal 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;
|
||||
}
|
||||
365
web/client/src/components/Common/DateRangePicker.vue
Normal file
365
web/client/src/components/Common/DateRangePicker.vue
Normal 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>
|
||||
24
web/client/src/components/Common/SpinnerWrapper.vue
Normal file
24
web/client/src/components/Common/SpinnerWrapper.vue
Normal 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>
|
||||
155
web/client/src/components/Home/WelcomeOnboarding.vue
Normal file
155
web/client/src/components/Home/WelcomeOnboarding.vue
Normal 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>
|
||||
67
web/client/src/components/Navigation/LanguageSwitcher.vue
Normal file
67
web/client/src/components/Navigation/LanguageSwitcher.vue
Normal 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>
|
||||
72
web/client/src/components/Support/SupportDashboard.vue
Normal file
72
web/client/src/components/Support/SupportDashboard.vue
Normal 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>
|
||||
63
web/client/src/composables/useBreakpoints.js
Normal file
63
web/client/src/composables/useBreakpoints.js
Normal 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
95
web/client/src/i18n.js
Normal 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
|
||||
120
web/client/src/locales/de.json
Normal file
120
web/client/src/locales/de.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
120
web/client/src/locales/en.json
Normal file
120
web/client/src/locales/en.json
Normal 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": "Can’t find what you’re 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
120
web/client/src/locales/es.json
Normal file
120
web/client/src/locales/es.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
120
web/client/src/locales/fr.json
Normal file
120
web/client/src/locales/fr.json
Normal 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": "Aujourd’hui ",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
120
web/client/src/locales/it.json
Normal file
120
web/client/src/locales/it.json
Normal 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
20
web/client/src/main.js
Normal 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')
|
||||
28
web/client/src/plugins/appBridge.js
Normal file
28
web/client/src/plugins/appBridge.js
Normal 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)
|
||||
}
|
||||
}
|
||||
46
web/client/src/router/index.js
Normal file
46
web/client/src/router/index.js
Normal 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
|
||||
61
web/client/src/services/useAuthenticatedFetch.js
Normal file
61
web/client/src/services/useAuthenticatedFetch.js
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
23
web/client/src/stores/billing.js
Normal file
23
web/client/src/stores/billing.js
Normal 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
|
||||
})
|
||||
19
web/client/src/stores/shopify.js
Normal file
19
web/client/src/stores/shopify.js
Normal 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
|
||||
}
|
||||
}
|
||||
})
|
||||
23
web/client/src/views/ExitIframeView.vue
Normal file
23
web/client/src/views/ExitIframeView.vue
Normal 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>
|
||||
9
web/client/src/views/HomeView.vue
Normal file
9
web/client/src/views/HomeView.vue
Normal file
@ -0,0 +1,9 @@
|
||||
<script setup>
|
||||
import WelcomeOnboarding from '@/components/Home/WelcomeOnboarding.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<WelcomeOnboarding />
|
||||
</main>
|
||||
</template>
|
||||
10
web/client/src/views/NotFoundView.vue
Normal file
10
web/client/src/views/NotFoundView.vue
Normal file
@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>404</h1>
|
||||
<p>Page not found</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup></script>
|
||||
|
||||
<style scoped></style>
|
||||
9
web/client/src/views/SupportView.vue
Normal file
9
web/client/src/views/SupportView.vue
Normal 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
75
web/client/vite.config.js
Normal 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
32
web/server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
4
web/server/shopify.web.toml
Normal file
4
web/server/shopify.web.toml
Normal file
@ -0,0 +1,4 @@
|
||||
type="backend"
|
||||
|
||||
[commands]
|
||||
dev = "npm run dev"
|
||||
@ -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
|
||||
);
|
||||
19
web/server/src/database/data/-- SQLite Stats One insert.sql
Normal file
19
web/server/src/database/data/-- SQLite Stats One insert.sql
Normal 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');
|
||||
|
||||
10001
web/server/src/database/data/-- SQLite Stats inserts.sql
Normal file
10001
web/server/src/database/data/-- SQLite Stats inserts.sql
Normal file
File diff suppressed because it is too large
Load Diff
22
web/server/src/database/database.js
Normal file
22
web/server/src/database/database.js
Normal 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
83
web/server/src/index.js
Normal 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)
|
||||
19
web/server/src/middlewares/user-middleware.js
Normal file
19
web/server/src/middlewares/user-middleware.js
Normal 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)
|
||||
}
|
||||
}
|
||||
108
web/server/src/models/postgresql/user.js
Normal file
108
web/server/src/models/postgresql/user.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
76
web/server/src/models/postgresql/webhooks.js
Normal file
76
web/server/src/models/postgresql/webhooks.js
Normal 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)
|
||||
}
|
||||
}
|
||||
38
web/server/src/routes/billing.js
Normal file
38
web/server/src/routes/billing.js
Normal 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
|
||||
75
web/server/src/routes/shop.js
Normal file
75
web/server/src/routes/shop.js
Normal 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
|
||||
34
web/server/src/services/shopify/billing.js
Normal file
34
web/server/src/services/shopify/billing.js
Normal 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]
|
||||
}
|
||||
77
web/server/src/services/shopify/shop.js
Normal file
77
web/server/src/services/shopify/shop.js
Normal 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
31
web/server/src/shopify.js
Normal 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
|
||||
16
web/server/src/utils/misc-utils.js
Normal file
16
web/server/src/utils/misc-utils.js
Normal 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
|
||||
}
|
||||
}
|
||||
80
web/server/src/utils/webhook-utils.js
Normal file
80
web/server/src/utils/webhook-utils.js
Normal 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} *************`)
|
||||
}
|
||||
}
|
||||
117
web/server/src/webhooks/webhooks.js
Normal file
117
web/server/src/webhooks/webhooks.js
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user